Merge branch 'tailwind2' into 'main'

Various admin improvements

Closes #973

See merge request framasoft/mobilizon!1152
This commit is contained in:
Thomas Citharel 2022-01-17 15:42:02 +00:00
commit e6b95a43d1
189 changed files with 16122 additions and 5708 deletions

66
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,66 @@
# Update the VARIANT arg in docker-compose.yml to pick an Elixir version: 1.9, 1.10, 1.10.4
ARG VARIANT="1.12.3"
FROM elixir:${VARIANT}
# This Dockerfile adds a non-root user with sudo access. Update the “remoteUser” property in
# devcontainer.json to use it. More info: https://aka.ms/vscode-remote/containers/non-root-user.
ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=$USER_UID
# Options for common package install script
ARG INSTALL_ZSH="true"
ARG UPGRADE_PACKAGES="true"
ARG COMMON_SCRIPT_SOURCE="https://raw.githubusercontent.com/microsoft/vscode-dev-containers/v0.209.6/script-library/common-debian.sh"
ARG COMMON_SCRIPT_SHA="d35dd1711454156c9a59cc41ebe04fbff681ca0bd304f10fd5b13285d0de13b2"
# Optional Settings for Phoenix
ARG PHOENIX_VERSION="1.6.2"
# [Optional] Setup nodejs
ARG NODE_SCRIPT_SOURCE="https://raw.githubusercontent.com/microsoft/vscode-dev-containers/main/script-library/node-debian.sh"
ARG NODE_SCRIPT_SHA="dev-mode"
ARG NODE_VERSION="none"
ENV NVM_DIR=/usr/local/share/nvm
ENV NVM_SYMLINK_CURRENT=true
ENV PATH=${NVM_DIR}/current/bin:${PATH}
# [Optional, Choice] Node.js version: none, lts/*, 16, 14, 12, 10
ARG NODE_VERSION="none"
# Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies.
RUN apt-get update \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends curl ca-certificates 2>&1 \
&& curl -sSL ${COMMON_SCRIPT_SOURCE} -o /tmp/common-setup.sh \
&& ([ "${COMMON_SCRIPT_SHA}" = "dev-mode" ] || (echo "${COMMON_SCRIPT_SHA} */tmp/common-setup.sh" | sha256sum -c -)) \
&& /bin/bash /tmp/common-setup.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" \
#
# [Optional] Install Node.js for use with web applications
&& if [ "$NODE_VERSION" != "none" ]; then \
curl -sSL ${NODE_SCRIPT_SOURCE} -o /tmp/node-setup.sh \
&& ([ "${NODE_SCRIPT_SHA}" = "dev-mode" ] || (echo "${NODE_SCRIPT_SHA} */tmp/node-setup.sh" | sha256sum -c -)) \
&& /bin/bash /tmp/node-setup.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}"; \
fi \
#
# Install dependencies
&& apt-get install -y build-essential \
#
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* /tmp/common-setup.sh /tmp/node-setup.sh
RUN su ${USERNAME} -c "mix local.hex --force \
&& mix local.rebar --force \
&& mix archive.install --force hex phx_new ${PHOENIX_VERSION}"
RUN apt-get update \
&& export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends cmake webp bash libncurses6 git python3 inotify-tools \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
# [Optional] Uncomment this line to install additional package.
# RUN mix ...

View file

@ -0,0 +1,44 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/elixir-phoenix-postgres
{
"name": "Elixir, Phoenix, Node.js & PostgresSQL (Community)",
"dockerComposeFile": "docker-compose.yml",
"service": "elixir",
"workspaceFolder": "/workspace",
// Set *default* container specific settings.json values on container create.
"settings": {
"sqltools.connections": [{
"name": "Container database",
"driver": "PostgreSQL",
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"database": "postgres",
"username": "postgres",
"password": "postgres"
}]
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"jakebecker.elixir-ls",
"mtxr.sqltools",
"mtxr.sqltools-driver-pg"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [4000, 4001, 5432],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "mix deps.get",
// "runArgs": ["--userns=keep-id", "--privileged"],
// "containerUser": "vscode",
// "containerEnv": {
// "HOME": "/home/vscode",
// },
// "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z",
// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
}

View file

@ -0,0 +1,46 @@
version: "3.8"
services:
elixir:
build:
context: .
dockerfile: Dockerfile
args:
# Elixir Version: 1.9, 1.10, 1.10.4, ...
VARIANT: "1.13.1"
# Phoenix Version: 1.4.17, 1.5.4, ...
PHOENIX_VERSION: "1.6.6"
# Node Version: 10, 11, ...
NODE_VERSION: "16"
volumes:
- ..:/workspace:z
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:db
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
environment:
MOBILIZON_INSTANCE_NAME: My Mobilizon Instance
MOBILIZON_INSTANCE_HOST: localhost
MOBILIZON_INSTANCE_HOST_PORT: 4000
MOBILIZON_INSTANCE_PORT: 4000
MOBILIZON_INSTANCE_EMAIL: noreply@mobilizon.me
MOBILIZON_INSTANCE_REGISTRATIONS_OPEN: "true"
MOBILIZON_DATABASE_PASSWORD: postgres
MOBILIZON_DATABASE_USERNAME: postgres
MOBILIZON_DATABASE_DBNAME: mobilizon
MOBILIZON_DATABASE_HOST: db
db:
image: postgis/postgis:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
volumes:
postgres-data: null

2
.tool-versions Normal file
View file

@ -0,0 +1,2 @@
elixir 1.13
erlang 24.2

View file

@ -290,6 +290,7 @@ config :mobilizon, Oban,
crontab: [
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background},
{"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background},
{"36 * * * *", Mobilizon.Service.Workers.RefreshInstances, queue: :background},
{"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background},
{"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background},
{"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background},

View file

@ -16,6 +16,7 @@
"@absinthe/socket-apollo-link": "^0.2.1",
"@apollo/client": "^3.3.16",
"@mdi/font": "^6.1.95",
"@tailwindcss/line-clamp": "^0.3.0",
"@tiptap/core": "^2.0.0-beta.41",
"@tiptap/extension-blockquote": "^2.0.0-beta.25",
"@tiptap/extension-bold": "^2.0.0-beta.24",
@ -41,6 +42,7 @@
"@vue-a11y/skip-to": "^2.1.2",
"@vue/apollo-option": "4.0.0-alpha.11",
"apollo-absinthe-upload-link": "^1.5.0",
"autoprefixer": "^10",
"blurhash": "^1.1.3",
"buefy": "^0.9.0",
"bulma-divider": "^0.2.0",
@ -57,8 +59,10 @@
"ngeohash": "^0.6.3",
"p-debounce": "^4.0.0",
"phoenix": "^1.6",
"postcss": "^8",
"register-service-worker": "^1.7.2",
"sanitize-html": "^2.5.3",
"tailwindcss": "^3",
"tippy.js": "^6.2.3",
"unfetch": "^4.2.0",
"v-tooltip": "^2.1.3",
@ -112,6 +116,7 @@
"sass-loader": "^12.0.0",
"ts-jest": "27",
"typescript": "~4.4.3",
"vue-cli-plugin-tailwind": "^3.0.0-beta.0",
"vue-i18n-extract": "^2.0.4",
"vue-template-compiler": "^2.6.11",
"webpack-cli": "^4.7.0"

6
js/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -216,7 +216,11 @@ export default class App extends Vue {
// Set the focus to the router view
// https://marcus.io/blog/accessible-routing-vuejs
setTimeout(() => {
const focusTarget = this.routerView?.$el as HTMLElement;
const focusTarget = (
this.routerView?.$refs?.componentFocusTarget !== undefined
? this.routerView?.$refs?.componentFocusTarget
: this.routerView?.$el
) as HTMLElement;
if (focusTarget) {
// Make focustarget programmatically focussable
focusTarget.setAttribute("tabindex", "-1");

View file

@ -70,6 +70,9 @@ export const typePolicies: TypePolicies = {
participantStats: { merge: replaceMergePolicy },
},
},
Instance: {
keyFields: ["domain"],
},
RootQueryType: {
fields: {
relayFollowers: paginatedLimitPagination<IFollower>(),

9
js/src/assets/logo.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60">
<path style="opacity:0;fill:#fea72b;fill-opacity:1;stroke:none;stroke-opacity:0" d="M-5.801-6.164h72.69v72.871h-72.69z" />
<g data-name="Calque 2">
<g data-name="header">
<path d="M26.58 27.06q0 8-4.26 12.3a12.21 12.21 0 0 1-9 3.42 12.21 12.21 0 0 1-9-3.42Q0 35.1 0 27.06q0-8.04 4.26-12.3a12.21 12.21 0 0 1 9-3.42 12.21 12.21 0 0 1 9 3.42q4.32 4.24 4.32 12.3zM13.29 17q-5.67 0-5.67 10.06t5.67 10.08q5.71 0 5.71-10.08T13.29 17z" style="fill:#3a384c;fill-opacity:1" transform="translate(14.627 5.256) scale(1.15671)" />
<path d="M9 6.78a7.37 7.37 0 0 1-.6-3 7.37 7.37 0 0 1 .6-3A8.09 8.09 0 0 1 12.83 0a7.05 7.05 0 0 1 3.69.84 7.37 7.37 0 0 1 .6 3 7.37 7.37 0 0 1-.6 3 7.46 7.46 0 0 1-3.87.84A6.49 6.49 0 0 1 9 6.78z" style="fill:#fff" transform="translate(14.627 5.256) scale(1.15671)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 920 B

View file

@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -7,10 +7,6 @@
@import "styles/vue-announcer.scss";
@import "styles/vue-skip-to.scss";
// a {
// color: $violet-2;
// }
a.out,
.content a,
.ProseMirror a {
@ -19,18 +15,10 @@ a.out,
text-decoration-thickness: 2px;
}
// input.input {
// border-color: $input-border-color !important;
// }
.section {
padding: 1rem 1% 4rem;
}
figure img.is-rounded {
border: 1px solid #cdcaea;
}
$color-black: #000;
.mention {

View file

@ -1,31 +1,75 @@
<template>
<div class="media" style="align-items: top" dir="auto">
<div class="media-left">
<figure class="image is-32x32" v-if="actor.avatar">
<img class="is-rounded" :src="actor.avatar.url" alt="" />
<div
class="w-80 bg-white rounded-lg shadow-md p-4 sm:p-8 flex items-center space-x-4 flex-col items-center pb-10"
>
<figure class="w-12 h-12" v-if="actor.avatar">
<img
class="rounded-lg"
:src="actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon v-else size="is-medium" icon="account-circle" />
</div>
<div class="media-content">
<p>
{{ actor.name || `@${usernameWithDomain(actor)}` }}
</p>
<p class="has-text-grey-dark" v-if="actor.name">
<b-icon
v-else
size="is-large"
icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
<h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
{{ displayName(actor) }}
</h5>
<p class="text-gray-500 truncate" v-if="actor.name">
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
</p>
<div
v-if="full"
class="summary"
:class="{ 'line-clamp-3': limit }"
v-html="actor.summary"
/>
</div>
<!-- <div
class="p-4 bg-white rounded-lg shadow-md sm:p-8 flex items-center space-x-4"
dir="auto"
>
<div class="flex-shrink-0">
<figure class="w-12 h-12" v-if="actor.avatar">
<img
class="rounded-lg"
:src="actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon
v-else
size="is-large"
icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
</div>
<div class="flex-1 min-w-0">
<h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
{{ displayName(actor) }}
</h5>
<p class="text-gray-500 truncate" v-if="actor.name">
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
</p>
<div
v-if="full"
class="line-clamp-3"
:class="{ limit: limit }"
v-html="actor.summary"
/>
</div>
</div>
</div> -->
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { IActor, usernameWithDomain } from "../../types/actor";
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
@Component
export default class ActorCard extends Vue {
@ -38,135 +82,7 @@ export default class ActorCard extends Vue {
@Prop({ required: false, type: Boolean, default: true }) limit!: boolean;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
}
</script>
<style lang="scss" scoped>
.summary.limit {
max-width: 25rem;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
</style>
<style lang="scss">
@use "@/styles/_mixins" as *;
.media {
.media-left {
margin-right: initial;
@include margin-right(1rem);
}
}
.tooltip {
display: block !important;
z-index: 10000;
.tooltip-inner {
background: black;
color: white;
border-radius: 16px;
padding: 5px 10px 4px;
}
.tooltip-arrow {
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: 5px;
border-color: black;
z-index: 1;
}
&[x-placement^="top"] {
margin-bottom: 5px;
.tooltip-arrow {
border-width: 5px 5px 0 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
bottom: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="bottom"] {
margin-top: 5px;
.tooltip-arrow {
border-width: 0 5px 5px 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
top: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^="right"] {
@include margin-left(5px);
.tooltip-arrow {
border-width: 5px 5px 5px 0;
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
left: -5px;
top: calc(50% - 5px);
@include margin-left(0);
@include margin-right(0);
}
}
&[x-placement^="left"] {
@include margin-right(5px);
.tooltip-arrow {
border-width: 5px 0 5px 5px;
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
right: -5px;
top: calc(50% - 5px);
@include margin-left(0);
@include margin-right(0);
}
}
&.popover {
$color: #f9f9f9;
.popover-inner {
background: lighten($background-color, 65%);
color: black;
padding: 24px;
border-radius: 5px;
box-shadow: 0 5px 30px rgba(black, 0.1);
}
.popover-arrow {
border-color: $color;
}
}
&[aria-hidden="true"] {
visibility: hidden;
opacity: 0;
transition: opacity 0.15s, visibility 0.15s;
}
&[aria-hidden="false"] {
visibility: visible;
opacity: 1;
transition: opacity 0.15s;
}
}
</style>

View file

@ -1,262 +0,0 @@
<template>
<div>
<b-table
v-show="relayFollowers.elements.length > 0"
:data="relayFollowers.elements"
:loading="$apollo.queries.relayFollowers.loading"
ref="table"
:checked-rows.sync="checkedRows"
detailed
:show-detail-icon="false"
paginated
backend-pagination
:current-page.sync="page"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="relayFollowers.total"
:per-page="FOLLOWERS_PER_PAGE"
@page-change="onFollowersPageChange"
checkable
checkbox-position="left"
>
<b-table-column
field="actor.id"
label="ID"
width="40"
numeric
v-slot="props"
>{{ props.row.actor.id }}</b-table-column
>
<b-table-column
field="actor.type"
:label="$t('Type')"
width="80"
v-slot="props"
>
<b-icon icon="lan" v-if="RelayMixin.isInstance(props.row.actor)" />
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column
field="approved"
:label="$t('Status')"
width="100"
sortable
centered
v-slot="props"
>
<span
:class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`"
>{{ props.row.approved ? $t("Accepted") : $t("Pending") }}</span
>
</b-table-column>
<b-table-column field="actor.domain" :label="$t('Domain')" sortable>
<template v-slot:default="props">
<a
@click="toggle(props.row)"
v-if="RelayMixin.isInstance(props.row.actor)"
>{{ props.row.actor.domain }}</a
>
<a @click="toggle(props.row)" v-else>{{
`${props.row.actor.preferredUsername}@${props.row.actor.domain}`
}}</a>
</template>
</b-table-column>
<b-table-column
field="targetActor.updatedAt"
:label="$t('Date')"
sortable
v-slot="props"
>
<span
:title="$options.filters.formatDateTimeString(props.row.updatedAt)"
>{{
formatDistanceToNow(new Date(props.row.updatedAt), {
locale: $dateFnsLocale,
})
}}</span
></b-table-column
>
<template #detail="props">
<article>
<div class="content">
<strong>{{ props.row.actor.name }}</strong>
<small v-if="props.row.actor.preferredUsername !== 'relay'"
>@{{ props.row.actor.preferredUsername }}</small
>
<p v-html="props.row.actor.summary" />
</div>
</article>
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<div class="buttons">
<b-button
@click="acceptRelays"
type="is-success"
v-if="checkedRowsHaveAtLeastOneToApprove"
>
{{
$tc(
"No instance to approve|Approve instance|Approve {number} instances",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
<b-button @click="rejectRelays" type="is-danger">
{{
$tc(
"No instance to reject|Reject instance|Reject {number} instances",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
</div>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowers.elements.length === 0">{{
$t("No instance follows your instance yet.")
}}</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from "vue-property-decorator";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { formatDistanceToNow } from "date-fns";
import {
ACCEPT_RELAY,
REJECT_RELAY,
RELAY_FOLLOWERS,
} from "../../graphql/admin";
import { IFollower } from "../../types/actor/follower.model";
import RelayMixin from "../../mixins/relay";
import RouteName from "@/router/name";
import { Paginate } from "@/types/paginate";
const FOLLOWERS_PER_PAGE = 10;
@Component({
apollo: {
relayFollowers: {
query: RELAY_FOLLOWERS,
variables() {
return {
page: this.page,
limit: FOLLOWERS_PER_PAGE,
};
},
},
},
metaInfo() {
return {
title: this.$t("Followers") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Followers extends Mixins(RelayMixin) {
RelayMixin = RelayMixin;
formatDistanceToNow = formatDistanceToNow;
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
checkedRows: IFollower[] = [];
FOLLOWERS_PER_PAGE = FOLLOWERS_PER_PAGE;
toggle(row: Record<string, unknown>): void {
this.table.toggleDetails(row);
}
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter(RouteName.RELAY_FOLLOWERS, {
page: page.toString(),
});
}
acceptRelays(): void {
this.checkedRows.forEach((row: IFollower) => {
this.acceptRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
rejectRelays(): void {
this.checkedRows.forEach((row: IFollower) => {
this.rejectRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
async acceptRelay(address: string): Promise<void> {
try {
await this.$apollo.mutate({
mutation: ACCEPT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
async rejectRelay(address: string): Promise<void> {
try {
await this.$apollo.mutate({
mutation: REJECT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
get checkedRowsHaveAtLeastOneToApprove(): boolean {
return this.checkedRows.some((checkedRow) => !checkedRow.approved);
}
async onFollowersPageChange(page: number): Promise<void> {
this.page = page;
try {
await this.$apollo.queries.relayFollowers.fetchMore({
variables: {
page: this.page,
limit: FOLLOWERS_PER_PAGE,
},
});
} catch (err: any) {
console.error(err);
}
}
}
</script>

View file

@ -1,311 +0,0 @@
<template>
<div>
<form @submit="followRelay">
<b-field
:label="$t('Add an instance')"
custom-class="add-relay"
horizontal
>
<b-field grouped expanded size="is-large">
<p class="control">
<b-input
v-model="newRelayAddress"
:placeholder="$t('Ex: mobilizon.fr')"
/>
</p>
<p class="control">
<b-button type="is-primary" native-type="submit">{{
$t("Add an instance")
}}</b-button>
</p>
</b-field>
</b-field>
</form>
<b-table
v-show="relayFollowings.elements.length > 0"
:data="relayFollowings.elements"
:loading="$apollo.queries.relayFollowings.loading"
ref="table"
:checked-rows.sync="checkedRows"
:is-row-checkable="(row) => row.id !== 3"
detailed
:show-detail-icon="false"
paginated
backend-pagination
:current-page.sync="page"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="relayFollowings.total"
:per-page="FOLLOWINGS_PER_PAGE"
@page-change="onFollowingsPageChange"
checkable
checkbox-position="left"
>
<b-table-column
field="targetActor.id"
label="ID"
width="40"
numeric
v-slot="props"
>{{ props.row.targetActor.id }}</b-table-column
>
<b-table-column
field="targetActor.type"
:label="$t('Type')"
width="80"
v-slot="props"
>
<b-icon
icon="lan"
v-if="RelayMixin.isInstance(props.row.targetActor)"
/>
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column
field="approved"
:label="$t('Status')"
width="100"
sortable
centered
v-slot="props"
>
<span
:class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`"
>{{ props.row.approved ? $t("Accepted") : $t("Pending") }}</span
>
</b-table-column>
<b-table-column field="targetActor.domain" :label="$t('Domain')" sortable>
<template v-slot:default="props">
<a
@click="toggle(props.row)"
v-if="RelayMixin.isInstance(props.row.targetActor)"
>{{ props.row.targetActor.domain }}</a
>
<a @click="toggle(props.row)" v-else>{{
`${props.row.targetActor.preferredUsername}@${props.row.targetActor.domain}`
}}</a>
</template>
</b-table-column>
<b-table-column
field="targetActor.updatedAt"
:label="$t('Date')"
sortable
v-slot="props"
>
<span
:title="$options.filters.formatDateTimeString(props.row.updatedAt)"
>{{
formatDistanceToNow(new Date(props.row.updatedAt), {
locale: $dateFnsLocale,
})
}}</span
></b-table-column
>
<template #detail="props">
<article>
<div class="content">
<strong>{{ props.row.targetActor.name }}</strong>
<small v-if="props.row.actor.preferredUsername !== 'relay'"
>@{{ props.row.targetActor.preferredUsername }}</small
>
<p v-html="props.row.targetActor.summary" />
</div>
</article>
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<b-button @click="removeRelays" type="is-danger">
{{
$tc(
"No instance to remove|Remove instance|Remove {number} instances",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowings.total === 0">{{
$t("You don't follow any instances yet.")
}}</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from "vue-property-decorator";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { formatDistanceToNow } from "date-fns";
import { ADD_RELAY, REMOVE_RELAY } from "../../graphql/admin";
import { IFollower } from "../../types/actor/follower.model";
import RelayMixin from "../../mixins/relay";
import { RELAY_FOLLOWINGS } from "@/graphql/admin";
import { Paginate } from "@/types/paginate";
import RouteName from "@/router/name";
import { ApolloCache, FetchResult, Reference } from "@apollo/client/core";
import gql from "graphql-tag";
const FOLLOWINGS_PER_PAGE = 10;
@Component({
apollo: {
relayFollowings: {
query: RELAY_FOLLOWINGS,
variables() {
return {
page: this.page,
limit: FOLLOWINGS_PER_PAGE,
};
},
},
},
metaInfo() {
return {
title: this.$t("Followings") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Followings extends Mixins(RelayMixin) {
newRelayAddress = "";
RelayMixin = RelayMixin;
formatDistanceToNow = formatDistanceToNow;
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
FOLLOWINGS_PER_PAGE = FOLLOWINGS_PER_PAGE;
checkedRows: IFollower[] = [];
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter(RouteName.RELAY_FOLLOWINGS, {
page: page.toString(),
});
}
async onFollowingsPageChange(page: number): Promise<void> {
this.page = page;
try {
await this.$apollo.queries.relayFollowings.fetchMore({
variables: {
page: this.page,
limit: FOLLOWINGS_PER_PAGE,
},
});
} catch (err: any) {
console.error(err);
}
}
async followRelay(e: Event): Promise<void> {
e.preventDefault();
try {
await this.$apollo.mutate<{ relayFollowings: Paginate<IFollower> }>({
mutation: ADD_RELAY,
variables: {
address: this.newRelayAddress.trim(), // trim to fix copy and paste domain name spaces and tabs
},
update(
cache: ApolloCache<{ relayFollowings: Paginate<IFollower> }>,
{ data }: FetchResult
) {
cache.modify({
fields: {
relayFollowings(
existingFollowings = { elements: [], total: 0 },
{ readField }
) {
const newFollowingRef = cache.writeFragment({
id: `${data?.addRelay.__typename}:${data?.addRelay.id}`,
data: data?.addRelay,
fragment: gql`
fragment NewFollowing on Follower {
id
}
`,
});
if (
existingFollowings.elements.some(
(ref: Reference) =>
readField("id", ref) === data?.addRelay.id
)
) {
return existingFollowings;
}
return {
total: existingFollowings.total + 1,
elements: [newFollowingRef, ...existingFollowings.elements],
};
},
},
broadcast: false,
});
},
});
this.newRelayAddress = "";
} catch (err: any) {
if (err.message) {
Snackbar.open({
message: err.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
removeRelays(): void {
this.checkedRows.forEach((row: IFollower) => {
this.removeRelay(row);
});
}
async removeRelay(follower: IFollower): Promise<void> {
const address = `${follower.targetActor.preferredUsername}@${follower.targetActor.domain}`;
try {
await this.$apollo.mutate<{ removeRelay: IFollower }>({
mutation: REMOVE_RELAY,
variables: {
address,
},
update(cache: ApolloCache<{ removeRelay: IFollower }>) {
cache.modify({
fields: {
relayFollowings(existingFollowingRefs, { readField }) {
return {
total: existingFollowingRefs.total - 1,
elements: existingFollowingRefs.elements.filter(
(followingRef: Reference) =>
follower.id !== readField("id", followingRef)
),
};
},
},
});
},
});
await this.$apollo.queries.relayFollowings.refetch();
this.checkedRows = [];
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
}
</script>

View file

@ -34,12 +34,6 @@
class="metadata-organized-by"
:title="$t('Organized by')"
>
<popover-actor-card
:actor="event.organizerActor"
v-if="!event.attributedTo"
>
<actor-card :actor="event.organizerActor" />
</popover-actor-card>
<router-link
v-if="event.attributedTo"
:to="{
@ -49,23 +43,19 @@
},
}"
>
<popover-actor-card
:actor="event.attributedTo"
<actor-card
v-if="
!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent
"
>
<actor-card :actor="event.attributedTo" />
</popover-actor-card>
:actor="event.attributedTo"
/>
<actor-card v-else :actor="event.organizerActor" />
</router-link>
<popover-actor-card
<actor-card
:actor="contact"
v-for="contact in event.contacts"
:key="contact.id"
>
<actor-card :actor="contact" />
</popover-actor-card>
/>
</event-metadata-block>
<event-metadata-block
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"

View file

@ -78,7 +78,7 @@
/>
<SettingMenuItem
:title="$t('Federation')"
:to="{ name: RouteName.RELAYS }"
:to="{ name: RouteName.INSTANCES }"
/>
</SettingMenuSection>
</ul>

View file

@ -0,0 +1,69 @@
<template>
<nav class="flex mb-3" :aria-label="$t('Breadcrumbs')">
<ol class="inline-flex items-center space-x-1 md:space-x-3 flex-wrap">
<li
class="inline-flex items-center"
v-for="(element, index) in links"
:key="index"
:aria-current="index > 0 ? 'page' : undefined"
>
<router-link
v-if="index === 0"
:to="element"
class="inline-flex items-center text-gray-800 hover:text-gray-900"
>
{{ element.text }}
</router-link>
<div class="flex items-center" v-else-if="index === links.length - 1">
<svg
class="w-6 h-6 text-gray-400 rtl:rotate-180"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
></path>
</svg>
<span
class="ltr:ml-1 rtl:mr-1 font-medium text-gray-600 md:ltr:ml-2 md:rtl:mr-2"
>{{ element.text }}</span
>
</div>
<div class="flex items-center" v-else>
<svg
class="w-6 h-6 text-gray-400 rtl:rotate-180"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
></path>
</svg>
<router-link
:to="element"
class="ltr:ml-1 rtl:mr-1 font-medium text-gray-800 hover:text-gray-900 md:ltr:ml-2 md:rtl:mr-2"
>{{ element.text }}</router-link
>
</div>
</li>
<slot></slot>
</ol>
</nav>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { Location } from "vue-router";
type LinkElement = Location & { text: string };
@Component
export default class Breadcrumbs extends Vue {
@Prop({ type: Array, required: true }) links!: LinkElement[];
}
</script>

View file

@ -70,6 +70,67 @@ export const RELAY_FOLLOWINGS = gql`
${RELAY_FRAGMENT}
`;
export const INSTANCE_FRAGMENT = gql`
fragment InstanceFragment on Instance {
domain
hasRelay
followerStatus
followedStatus
eventCount
personCount
groupCount
followersCount
followingsCount
reportsCount
mediaSize
}
`;
export const INSTANCE = gql`
query instance($domain: ID!) {
instance(domain: $domain) {
...InstanceFragment
}
}
${INSTANCE_FRAGMENT}
`;
export const INSTANCES = gql`
query Instances(
$page: Int
$limit: Int
$orderBy: InstancesSortFields
$direction: String
$filterDomain: String
$filterFollowStatus: InstanceFilterFollowStatus
$filterSuspendStatus: InstanceFilterSuspendStatus
) {
instances(
page: $page
limit: $limit
orderBy: $orderBy
direction: $direction
filterDomain: $filterDomain
filterFollowStatus: $filterFollowStatus
filterSuspendStatus: $filterSuspendStatus
) {
total
elements {
...InstanceFragment
}
}
}
${INSTANCE_FRAGMENT}
`;
export const ADD_INSTANCE = gql`
mutation addInstance($domain: String!) {
addInstance(domain: $domain) {
...InstanceFragment
}
}
${INSTANCE_FRAGMENT}
`;
export const ADD_RELAY = gql`
mutation addRelay($address: String!) {
addRelay(address: $address) {
@ -190,3 +251,26 @@ export const SAVE_ADMIN_SETTINGS = gql`
}
${ADMIN_SETTINGS_FRAGMENT}
`;
export const ADMIN_UPDATE_USER = gql`
mutation AdminUpdateUser(
$id: ID!
$email: String
$role: UserRole
$confirmed: Boolean
$notify: Boolean
) {
adminUpdateUser(
id: $id
email: $email
role: $role
confirmed: $confirmed
notify: $notify
) {
id
email
role
confirmedAt
}
}
`;

View file

@ -2,8 +2,13 @@ import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const REPORTS = gql`
query Reports($status: ReportStatus, $page: Int, $limit: Int) {
reports(status: $status, page: $page, limit: $limit) {
query Reports(
$status: ReportStatus
$domain: String
$page: Int
$limit: Int
) {
reports(status: $status, domain: $domain, page: $page, limit: $limit) {
total
elements {
id

View file

@ -209,14 +209,30 @@ export const UPDATE_ACTIVITY_SETTING = gql`
`;
export const LIST_USERS = gql`
query ListUsers($email: String, $page: Int, $limit: Int) {
users(email: $email, page: $page, limit: $limit) {
query ListUsers(
$email: String
$currentSignInIp: String
$page: Int
$limit: Int
$sort: SortableUserField
$direction: SortDirection
) {
users(
email: $email
currentSignInIp: $currentSignInIp
page: $page
limit: $limit
sort: $sort
direction: $direction
) {
total
elements {
id
email
locale
confirmedAt
currentSignInIp
currentSignInAt
disabled
actors {
...ActorFragment

View file

@ -1260,5 +1260,52 @@
"This profile was not found": "This profile was not found",
"Back to profile list": "Back to profile list",
"This user was not found": "This user was not found",
"Back to user list": "Back to user list"
"Back to user list": "Back to user list",
"Stop following instance": "Stop following instance",
"Follow instance": "Follow instance",
"Accept follow": "Accept follow",
"Reject follow": "Reject follow",
"This instance doesn't follow yours.": "This instance doesn't follow yours.",
"Only Mobilizon instances can be followed": "",
"Follow a new instance": "Follow a new instance",
"Follow status": "Follow status",
"All": "All",
"Following": "Following",
"Followed": "Followed",
"Followed, pending response": "Followed, pending response",
"Follows us": "Follows us",
"Follows us, pending approval": "Follows us, pending approval",
"No instance found.": "No instance found.",
"No instances match this filter. Try resetting filter fields?": "No instances match this filter. Try resetting filter fields?",
"You haven't interacted with other instances yet.": "You haven't interacted with other instances yet.",
"mobilizon-instance.tld": "mobilizon-instance.tld",
"Report status": "Report status",
"access the corresponding account": "access the corresponding account",
"Organized events": "Organized events",
"Memberships": "Memberships",
"This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.": "This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.",
"Total number of participations": "Total number of participations",
"Uploaded media total size": "Uploaded media total size",
"0 Bytes": "0 Bytes",
"Change email": "Change email",
"Confirm user": "Confirm user",
"Change role": "Change role",
"The user has been disabled": "The user has been disabled",
"This user doesn't have any profiles": "This user doesn't have any profiles",
"Edit user email": "Edit user email",
"Change user email": "Change user email",
"Previous email": "Previous email",
"Notify the user of the change": "Notify the user of the change",
"Change user role": "Change user role",
"Suspend the account?": "Suspend the account?",
"Do you really want to suspend this account? All of the user's profiles will be deleted.": "Do you really want to suspend this account? All of the user's profiles will be deleted.",
"Suspend the account": "Suspend the account",
"No user matches the filter": "No user matches the filter",
"new@email.com": "new@email.com",
"Other users with the same email domain": "Other users with the same email domain",
"Other users with the same IP address": "Other users with the same IP address",
"IP Address": "IP Address",
"Last seen on": "Last seen on",
"No user matches the filters": "No user matches the filters",
"Reset filters": "Reset filters"
}

View file

@ -288,7 +288,7 @@
"Either the participation request has already been validated, either the validation token is incorrect.": "Soit la demande de participation a déjà été validée, soit le jeton de validation est incorrect.",
"Element title": "Titre de l'élement",
"Element value": "Valeur de l'élement",
"Email": "Email",
"Email": "Courriel",
"Email address": "Adresse email",
"Email validate": "Validation de l'email",
"Emails usually don't contain capitals, make sure you haven't made a typo.": "Les emails ne contiennent d'ordinaire pas de capitales, assurez-vous de n'avoir pas fait de faute de frappe.",
@ -1260,5 +1260,52 @@
"{timezoneLongName} ({timezoneShortName})": "{timezoneLongName} ({timezoneShortName})",
"{title} ({count} todos)": "{title} ({count} todos)",
"{username} was invited to {group}": "{username} a été invité à {group}",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Stop following instance": "Arrêter de suivre l'instance",
"Follow instance": "Suivre l'instance",
"Accept follow": "Accepter le suivi",
"Reject follow": "Rejetter le suivi",
"This instance doesn't follow yours.": "Cette instance ne suit pas la vôtre.",
"Only Mobilizon instances can be followed": "Seules les instances Mobilizon peuvent être suivies",
"Follow a new instance": "Suivre une nouvelle instance",
"Follow status": "Statut du suivi",
"All": "Toutes",
"Following": "Suivantes",
"Followed": "Suivies",
"Followed, pending response": "Suivie, en attente de la réponse",
"Follows us": "Nous suit",
"Follows us, pending approval": "Nous suit, en attente de validation",
"No instance found": "Aucune instance trouvée",
"No instances match this filter. Try resetting filter fields?": "Aucune instance ne correspond à ce filtre. Essayer de remettre à zéro les champs des filtres ?",
"You haven't interacted with other instances yet.": "Vous n'avez interagi avec encore aucune autre instance.",
"mobilizon-instance.tld": "instance-mobilizon.tld",
"Report status": "Statut du signalement",
"access the corresponding account": "accéder au compte correspondant",
"Organized events": "Événements organisés",
"Memberships": "Adhésions",
"This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.": "Ce profil se situe sur cette instance, vous devez donc {access_the_corresponding_account} afin de le suspendre.",
"Total number of participations": "Nombre total de participations",
"Uploaded media total size": "Taille totale des médias téléversés",
"0 Bytes": "0 octets",
"Change email": "Changer l'email",
"Confirm user": "Confirmer l'utilisateur⋅ice",
"Change role": "Changer le role",
"The user has been disabled": "L'utilisateur⋅ice a été désactivé",
"This user doesn't have any profiles": "Cet utilisateur⋅ice n'a aucun profil",
"Edit user email": "Éditer l'email de l'utilisateur⋅ice",
"Change user email": "Modifier l'email de l'utilisateur⋅ice",
"Previous email": "Email précédent",
"Notify the user of the change": "Notifier l'utilisateur du changement",
"Change user role": "Changer le role de l'utilisateur",
"Suspend the account?": "Suspendre le compte ?",
"Do you really want to suspend this account? All of the user's profiles will be deleted.": "Voulez-vous vraiment suspendre ce compte ? Tous les profils de cet⋅te utilisateur⋅ice seront supprimés.",
"Suspend the account": "Suspendre le compte",
"No user matches the filter": "Aucun⋅e utilisateur⋅ice ne correspond au filtre",
"new@email.com": "nouvel@email.com",
"Other users with the same email domain": "Autres utilisateur⋅ices avec le même domaine de courriel",
"Other users with the same IP address": "Autres utilisateur⋅ices avec la même adresse IP",
"IP Address": "Adresse IP",
"Last seen on": "Vu pour la dernière fois",
"No user matches the filters": "Aucun⋅e utilisateur⋅ice ne correspond aux filtres",
"Reset filters": "Réinitialiser les filtres"
}

View file

@ -12,7 +12,9 @@ import { NotifierPlugin } from "./plugins/notifier";
import filters from "./filters";
import { i18n } from "./utils/i18n";
import apolloProvider from "./vue-apollo";
import Breadcrumbs from "@/components/Utils/Breadcrumbs.vue";
import "./registerServiceWorker";
import "./assets/tailwind.css";
Vue.config.productionTip = false;
@ -24,6 +26,7 @@ Vue.use(VueScrollTo);
Vue.use(VTooltip);
Vue.use(VueAnnouncer);
Vue.use(VueSkipTo);
Vue.component("breadcrumbs-nav", Breadcrumbs);
// Register the router hooks with their names
Component.registerHooks([

View file

@ -1,38 +0,0 @@
import { IActor } from "@/types/actor";
import { ActorType } from "@/types/enums";
import { Component, Vue, Ref } from "vue-property-decorator";
import VueRouter from "vue-router";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
@Component
export default class RelayMixin extends Vue {
@Ref("table") readonly table!: any;
toggle(row: Record<string, unknown>): void {
this.table.toggleDetails(row);
}
protected async pushRouter(
routeName: string,
args: Record<string, string>
): Promise<void> {
try {
await this.$router.push({
name: routeName,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
static isInstance(actor: IActor): boolean {
return (
actor.type === ActorType.APPLICATION &&
(actor.preferredUsername === "relay" ||
actor.preferredUsername === actor.domain)
);
}
}

View file

@ -11,9 +11,8 @@ export enum SettingsRouteName {
ADMIN = "ADMIN",
ADMIN_DASHBOARD = "ADMIN_DASHBOARD",
ADMIN_SETTINGS = "ADMIN_SETTINGS",
RELAYS = "Relays",
RELAY_FOLLOWINGS = "Followings",
RELAY_FOLLOWERS = "Followers",
INSTANCES = "INSTANCES",
INSTANCE = "INSTANCE",
USERS = "USERS",
PROFILES = "PROFILES",
ADMIN_PROFILE = "ADMIN_PROFILE",
@ -21,7 +20,7 @@ export enum SettingsRouteName {
ADMIN_GROUPS = "ADMIN_GROUPS",
ADMIN_GROUP_PROFILE = "ADMIN_GROUP_PROFILE",
MODERATION = "MODERATION",
REPORTS = "Reports",
REPORTS = "REPORTS",
REPORT = "Report",
REPORT_LOGS = "Logs",
CREATE_IDENTITY = "CreateIdentity",
@ -199,44 +198,35 @@ export const settingsRoutes: RouteConfig[] = [
meta: { requiredAuth: true, announcer: { skip: true } },
},
{
path: "admin/relays",
name: SettingsRouteName.RELAYS,
redirect: { name: SettingsRouteName.RELAY_FOLLOWINGS },
component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "Follows" */ "@/views/Admin/Follows.vue"),
meta: { requiredAuth: true, announcer: { skip: true } },
children: [
{
path: "followings",
name: SettingsRouteName.RELAY_FOLLOWINGS,
path: "admin/instances",
name: SettingsRouteName.INSTANCES,
component: (): Promise<ImportedComponent> =>
import(
/* webpackChunkName: "Followings" */ "@/components/Admin/Followings.vue"
/* webpackChunkName: "Instances" */ "@/views/Admin/Instances.vue"
),
meta: {
requiredAuth: true,
announcer: {
message: (): string => i18n.t("Followings") as string,
message: (): string => i18n.t("Instances") as string,
},
},
},
{
path: "followers",
name: SettingsRouteName.RELAY_FOLLOWERS,
component: (): Promise<ImportedComponent> =>
import(
/* webpackChunkName: "Followers" */ "@/components/Admin/Followers.vue"
),
meta: {
requiredAuth: true,
announcer: {
message: (): string => i18n.t("Followers") as string,
},
},
},
],
props: true,
},
{
path: "admin/instances/:domain",
name: SettingsRouteName.INSTANCE,
component: (): Promise<ImportedComponent> =>
import(
/* webpackChunkName: "Instance" */ "@/views/Admin/Instance.vue"
),
props: true,
meta: {
requiredAuth: true,
announcer: {
message: (): string => i18n.t("Instance") as string,
},
},
},
{
path: "/moderation",
name: SettingsRouteName.MODERATION,
@ -244,7 +234,7 @@ export const settingsRoutes: RouteConfig[] = [
meta: { requiredAuth: true, announcer: { skip: true } },
},
{
path: "/moderation/reports/:filter?",
path: "/moderation/reports",
name: SettingsRouteName.REPORTS,
component: (): Promise<ImportedComponent> =>
import(

View file

@ -276,3 +276,15 @@ export enum EventMetadataCategories {
BOOKING = "BOOKING",
VIDEO_CONFERENCE = "VIDEO_CONFERENCE",
}
export enum InstanceFilterFollowStatus {
ALL = "ALL",
FOLLOWING = "FOLLOWING",
FOLLOWED = "FOLLOWED",
}
export enum InstanceFollowStatus {
APPROVED = "APPROVED",
PENDING = "PENDING",
NONE = "NONE",
}

View file

@ -0,0 +1,14 @@
import { InstanceFollowStatus } from "./enums";
export interface IInstance {
domain: string;
hasRelay: boolean;
followerStatus: InstanceFollowStatus;
followedStatus: InstanceFollowStatus;
personCount: number;
groupCount: number;
followersCount: number;
followingsCount: number;
reportsCount: number;
mediaSize: number;
}

View file

@ -19,8 +19,8 @@ function localeShortWeekDayNames(): string[] {
}
// https://stackoverflow.com/a/18650828/10204399
function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 Bytes";
function formatBytes(bytes: number, decimals = 2, zero = "0 Bytes"): string {
if (bytes === 0) return zero;
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;

View file

@ -139,3 +139,17 @@ $subtitle-sup-size: 15px;
$breadcrumb-item-color: $primary;
$checkbox-background-color: #fff;
$title-color: $violet-3;
:root {
--color-primary: 30 125 151;
--color-secondary: 255 213 153;
--color-violet-title: 66 64 86;
}
@media (prefers-color-scheme: dark) {
:root {
--color-primary: 30 125 151;
--color-secondary: 255 213 153;
--color-violet-title: 66 64 86;
}
}

View file

@ -1,28 +1,6 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.IDENTITIES }">{{
$t("Profiles")
}}</router-link>
</li>
<li class="is-active" v-if="isUpdate && identity">
<router-link
:to="{
name: RouteName.UPDATE_IDENTITY,
params: { identityName: identity.preferredUsername },
}"
>{{ identity.name }}</router-link
>
</li>
<li class="is-active" v-else>
<router-link :to="{ name: RouteName.CREATE_IDENTITY }">{{
$t("New profile")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav :links="breadcrumbsLinks" />
<div class="root" v-if="identity">
<h1 class="title">
<span v-if="isUpdate">{{ identity.displayName() }}</span>
@ -253,6 +231,7 @@ import { ServerParseError } from "@apollo/client/link/http";
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
import pick from "lodash/pick";
import { ActorType } from "@/types/enums";
import { Location } from "vue-router";
@Component({
components: {
@ -670,5 +649,29 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
this.oldDisplayName = null;
this.avatarFile = null;
}
get breadcrumbsLinks(): (Location & { text: string })[] {
const links = [
{
name: RouteName.IDENTITIES,
params: {},
text: this.$t("Profiles") as string,
},
];
if (this.isUpdate && this.identity) {
links.push({
name: RouteName.UPDATE_IDENTITY,
params: { identityName: this.identity.preferredUsername },
text: this.identity.name,
});
} else {
links.push({
name: RouteName.CREATE_IDENTITY,
params: {},
text: this.$t("New profile") as string,
});
}
return links;
}
}
</script>

View file

@ -1,31 +1,19 @@
<template>
<div v-if="group" class="section">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ADMIN }">{{
$t("Admin")
}}</router-link>
</li>
<li>
<router-link
:to="{
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{
name: RouteName.ADMIN_GROUPS,
}"
>{{ $t("Groups") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: $t('Groups'),
},
{
name: RouteName.PROFILES,
params: { id: group.id },
}"
>{{ group.name || usernameWithDomain(group) }}</router-link
>
</li>
</ul>
</nav>
text: displayName(group),
},
]"
/>
<div class="actor-card">
<p v-if="group.suspended">
<actor-card
@ -305,7 +293,11 @@ import { formatBytes } from "@/utils/datetime";
import { MemberRole } from "@/types/enums";
import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
import { IGroup } from "../../types/actor";
import { usernameWithDomain, IActor } from "../../types/actor/actor.model";
import {
usernameWithDomain,
displayName,
IActor,
} from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import ActorCard from "../../components/Account/ActorCard.vue";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
@ -359,6 +351,8 @@ export default class AdminGroupProfile extends Vue {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
RouteName = RouteName;
EVENTS_PER_PAGE = EVENTS_PER_PAGE;

View file

@ -1,32 +1,21 @@
<template>
<div v-if="person" class="section">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ADMIN }">{{
$t("Admin")
}}</router-link>
</li>
<li>
<router-link
:to="{
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{
name: RouteName.PROFILES,
}"
>{{ $t("Profiles") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: $t('Profiles'),
},
{
name: RouteName.PROFILES,
params: { id: person.id },
}"
>{{ person.name || person.preferredUsername }}</router-link
>
</li>
</ul>
</nav>
<div class="actor-card">
text: displayName(person),
},
]"
/>
<div class="flex justify-center">
<actor-card
:actor="person"
:full="true"
@ -34,20 +23,47 @@
:limit="false"
/>
</div>
<table v-if="metadata.length > 0" class="table is-fullwidth">
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Details") }}</h2>
<div class="flex flex-col">
<div class="overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block py-2 min-w-full sm:px-2 lg:px-8">
<div class="overflow-hidden shadow-md sm:rounded-lg">
<table v-if="metadata.length > 0" class="min-w-full">
<tbody>
<tr v-for="{ key, value, link } in metadata" :key="key">
<td>{{ key }}</td>
<td v-if="link">
<tr
v-for="{ key, value, link } in metadata"
:key="key"
class="odd:bg-white even:bg-gray-50 border-b"
>
<td class="py-4 px-2 whitespace-nowrap">
{{ key }}
</td>
<td
v-if="link"
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap"
>
<router-link :to="link">
{{ value }}
</router-link>
</td>
<td v-else>{{ value }}</td>
<td
v-else
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap"
>
{{ value }}
</td>
</tr>
</tbody>
</table>
<div class="buttons">
</div>
</div>
</div>
</div>
</section>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Actions") }}</h2>
<div class="buttons" v-if="person.domain">
<b-button
@click="suspendProfile"
v-if="person.domain && !person.suspended"
@ -61,14 +77,30 @@
>{{ $t("Unsuspend") }}</b-button
>
</div>
<section>
<h2 class="subtitle">
{{
$tc("{number} organized events", person.organizedEvents.total, {
number: person.organizedEvents.total,
})
}}
</h2>
<p v-else></p>
<div
v-if="person.user"
class="p-4 mb-4 text-sm text-blue-700 bg-blue-100 rounded-lg"
role="alert"
>
<i18n
path="This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it."
>
<template #access_the_corresponding_account>
<router-link
class="underline"
:to="{
name: RouteName.ADMIN_USER_PROFILE,
params: { id: person.user.id },
}"
>{{ $t("access the corresponding account") }}</router-link
>
</template>
</i18n>
</div>
</section>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Organized events") }}</h2>
<b-table
:data="person.organizedEvents.elements"
:loading="$apollo.queries.person.loading"
@ -104,14 +136,8 @@
</template>
</b-table>
</section>
<section>
<h2 class="subtitle">
{{
$tc("{number} participations", person.participations.total, {
number: person.participations.total,
})
}}
</h2>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Participations") }}</h2>
<b-table
:data="
person.participations.elements.map(
@ -151,14 +177,8 @@
</template>
</b-table>
</section>
<section>
<h2 class="subtitle">
{{
$tc("{number} memberships", person.memberships.total, {
number: person.memberships.total,
})
}}
</h2>
<section class="mt-4 mb-3">
<h2 class="text-lg font-bold">{{ $t("Memberships") }}</h2>
<b-table
:data="person.memberships.elements"
:loading="$apollo.loading"
@ -279,7 +299,7 @@ import {
UNSUSPEND_PROFILE,
} from "../../graphql/actor";
import { IPerson } from "../../types/actor";
import { usernameWithDomain } from "../../types/actor/actor.model";
import { displayName, usernameWithDomain } from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import ActorCard from "../../components/Account/ActorCard.vue";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
@ -334,6 +354,8 @@ export default class AdminProfile extends Vue {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
RouteName = RouteName;
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
@ -384,6 +406,12 @@ export default class AdminProfile extends Vue {
{
key: this.$t("Domain") as string,
value: this.person.domain ? this.person.domain : this.$t("Local"),
link: this.person.domain
? {
name: RouteName.INSTANCE,
params: { domain: this.person.domain },
}
: undefined,
},
{
key: this.$i18n.t("Uploaded media size"),
@ -515,16 +543,3 @@ export default class AdminProfile extends Vue {
}
}
</script>
<style lang="scss" scoped>
table,
section {
margin: 2rem 0;
}
.actor-card {
background: #fff;
padding: 1.5rem;
border-radius: 10px;
}
</style>

View file

@ -1,74 +1,336 @@
<template>
<div v-if="user" class="section">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ADMIN }">{{
$t("Admin")
}}</router-link>
</li>
<li>
<router-link
:to="{
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{
name: RouteName.USERS,
}"
>{{ $t("Users") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: $t('Users'),
},
{
name: RouteName.ADMIN_USER_PROFILE,
params: { id: user.id },
}"
>{{ user.email }}</router-link
>
</li>
</ul>
</nav>
<table v-if="metadata.length > 0" class="table is-fullwidth">
text: user.email,
},
]"
/>
<section>
<h2 class="text-lg font-bold mb-3">{{ $t("Details") }}</h2>
<div class="flex flex-col">
<div class="overflow-x-auto sm:-mx-6">
<div class="inline-block py-2 min-w-full sm:px-2">
<div class="overflow-hidden shadow-md sm:rounded-lg">
<table v-if="metadata.length > 0" class="min-w-full">
<tbody>
<tr v-for="{ key, value, link, elements, type } in metadata" :key="key">
<td>{{ key }}</td>
<td v-if="elements && elements.length > 0">
<ul
v-for="{ value, link: elementLink, active } in elements"
:key="value"
<tr
class="odd:bg-white even:bg-gray-50 border-b"
v-for="{ key, value, link, type } in metadata"
:key="key"
>
<li>
<router-link :to="elementLink">
<span v-if="active">{{
$t("{profile} (by default)", { profile: value })
}}</span>
<span v-else>{{ value }}</span>
</router-link>
</li>
</ul>
<td class="py-4 px-2 whitespace-nowrap align-middle">
{{ key }}
</td>
<td v-else-if="elements">
{{ $t("None") }}
</td>
<td v-else-if="link">
<td v-if="link" class="py-4 px-2 whitespace-nowrap">
<router-link :to="link">
{{ value }}
</router-link>
</td>
<td v-else-if="type == 'code'">
<td
v-else-if="type === 'ip'"
class="py-4 px-2 whitespace-nowrap"
>
<code>{{ value }}</code>
</td>
<td v-else>{{ value }}</td>
<td
v-else-if="type === 'role'"
class="py-4 px-2 whitespace-nowrap"
>
<span
:class="{
'bg-red-100 text-red-800':
user.role == ICurrentUserRole.ADMINISTRATOR,
'bg-yellow-100 text-yellow-800':
user.role == ICurrentUserRole.MODERATOR,
'bg-blue-100 text-blue-800':
user.role == ICurrentUserRole.USER,
}"
class="text-sm font-medium mr-2 px-2.5 py-0.5 rounded"
>
{{ value }}
</span>
</td>
<td v-else class="py-4 px-2 align-middle">
{{ value }}
</td>
<td
v-if="type === 'email'"
class="py-4 px-2 whitespace-nowrap flex flex flex-col items-start"
>
<b-button
size="is-small"
v-if="!user.disabled"
@click="isEmailChangeModalActive = true"
type="is-text"
icon-left="pencil"
>{{ $t("Change email") }}</b-button
>
<b-button
tag="router-link"
:to="{
name: RouteName.USERS,
query: { emailFilter: `@${userEmailDomain}` },
}"
size="is-small"
type="is-text"
icon-left="magnify"
>{{
$t("Other users with the same email domain")
}}</b-button
>
</td>
<td
v-else-if="type === 'confirmed'"
class="py-4 px-2 whitespace-nowrap flex items-center"
>
<b-button
size="is-small"
v-if="!user.confirmedAt || !user.disabled"
@click="isConfirmationModalActive = true"
type="is-text"
icon-left="check"
>{{ $t("Confirm user") }}</b-button
>
</td>
<td
v-else-if="type === 'role'"
class="py-4 px-2 whitespace-nowrap flex items-center"
>
<b-button
size="is-small"
v-if="!user.disabled"
@click="isRoleChangeModalActive = true"
type="is-text"
icon-left="chevron-double-up"
>{{ $t("Change role") }}</b-button
>
</td>
<td
v-else-if="type === 'ip' && user.currentSignInIp"
class="py-4 px-2 whitespace-nowrap flex items-center"
>
<b-button
tag="router-link"
:to="{
name: RouteName.USERS,
query: { ipFilter: user.currentSignInIp },
}"
size="is-small"
type="is-text"
icon-left="web"
>{{
$t("Other users with the same IP address")
}}</b-button
>
</td>
<td v-else></td>
</tr>
</tbody>
</table>
<div class="buttons">
<b-button
@click="deleteAccount"
v-if="!user.disabled"
type="is-primary"
>{{ $t("Suspend") }}</b-button
>
</div>
</div>
</div>
</div>
</section>
<section class="my-4">
<h2 class="text-lg font-bold mb-3">{{ $t("Profiles") }}</h2>
<div
class="flex flex-wrap justify-center sm:justify-start gap-4"
v-if="profiles.length > 0"
>
<router-link
v-for="profile in profiles"
:key="profile.id"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: profile.id } }"
>
<actor-card
:actor="profile"
:full="true"
:popover="false"
:limit="true"
/>
</router-link>
</div>
<empty-content v-else-if="!$apollo.loading" :inline="true" icon="account">
{{ $t("This user doesn't have any profiles") }}
</empty-content>
</section>
<section class="my-4">
<h2 class="text-lg font-bold mb-3">{{ $t("Actions") }}</h2>
<div class="buttons" v-if="!user.disabled">
<b-button @click="suspendAccount" type="is-danger">{{
$t("Suspend")
}}</b-button>
</div>
<div
v-else
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
{{ $t("The user has been disabled") }}
</div>
</section>
<b-modal
:active="isEmailChangeModalActive"
has-modal-card
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="$t('Edit user email')"
:close-button-aria-label="$t('Close')"
aria-modal
>
<template>
<form @submit.prevent="updateUserEmail">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Change user email") }}</p>
<button
type="button"
class="delete"
@click="isEmailChangeModalActive = false"
/>
</header>
<section class="modal-card-body">
<b-field :label="$t('Previous email')">
<b-input type="email" :value="user.email" disabled> </b-input>
</b-field>
<b-field :label="$t('New email')">
<b-input
type="email"
v-model="newUser.email"
:placeholder="$t('new@email.com')"
required
>
</b-input>
</b-field>
<b-checkbox v-model="newUser.notify">{{
$t("Notify the user of the change")
}}</b-checkbox>
</section>
<footer class="modal-card-foot">
<b-button @click="isEmailChangeModalActive = false">{{
$t("Close")
}}</b-button>
<b-button native-type="submit" type="is-primary">{{
$t("Change email")
}}</b-button>
</footer>
</div>
</form>
</template>
</b-modal>
<b-modal
:active="isRoleChangeModalActive"
has-modal-card
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="$t('Edit user email')"
:close-button-aria-label="$t('Close')"
aria-modal
>
<template>
<form @submit.prevent="updateUserRole">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Change user role") }}</p>
<button
type="button"
class="delete"
@click="isRoleChangeModalActive = false"
/>
</header>
<section class="modal-card-body">
<b-field>
<b-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.ADMINISTRATOR"
>
{{ $t("Administrator") }}
</b-radio>
</b-field>
<b-field>
<b-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.MODERATOR"
>
{{ $t("Moderator") }}
</b-radio>
</b-field>
<b-field>
<b-radio
v-model="newUser.role"
:native-value="ICurrentUserRole.USER"
>
{{ $t("User") }}
</b-radio>
</b-field>
<b-checkbox v-model="newUser.notify">{{
$t("Notify the user of the change")
}}</b-checkbox>
</section>
<footer class="modal-card-foot">
<b-button @click="isRoleChangeModalActive = false">{{
$t("Close")
}}</b-button>
<b-button native-type="submit" type="is-primary">{{
$t("Change role")
}}</b-button>
</footer>
</div>
</form>
</template>
</b-modal>
<b-modal
:active="isConfirmationModalActive"
has-modal-card
trap-focus
:destroy-on-hide="false"
aria-role="dialog"
:aria-label="$t('Edit user email')"
:close-button-aria-label="$t('Close')"
aria-modal
>
<template>
<form @submit.prevent="confirmUser">
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Confirm user") }}</p>
<button
type="button"
class="delete"
@click="isConfirmationModalActive = false"
/>
</header>
<section class="modal-card-body">
<b-checkbox v-model="newUser.notify">{{
$t("Notify the user of the change")
}}</b-checkbox>
</section>
<footer class="modal-card-foot">
<b-button @click="isConfirmationModalActive = false">{{
$t("Close")
}}</b-button>
<b-button native-type="submit" type="is-primary">{{
$t("Confirm user")
}}</b-button>
</footer>
</div>
</form>
</template>
</b-modal>
</div>
<empty-content v-else-if="!$apollo.loading" icon="account">
{{ $t("This user was not found") }}
<template #desc>
@ -82,16 +344,16 @@
</empty-content>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { Route } from "vue-router";
import { Component, Vue, Prop, Watch } from "vue-property-decorator";
import { formatBytes } from "@/utils/datetime";
import { ICurrentUserRole } from "@/types/enums";
import { GET_USER, SUSPEND_USER } from "../../graphql/user";
import { usernameWithDomain } from "../../types/actor/actor.model";
import { IActor, usernameWithDomain } from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import { IUser } from "../../types/current-user.model";
import { IPerson } from "../../types/actor";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import ActorCard from "../../components/Account/ActorCard.vue";
import { ADMIN_UPDATE_USER, LANGUAGES_CODES } from "@/graphql/admin";
@Component({
apollo: {
@ -107,6 +369,17 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
return !this.id;
},
},
languages: {
query: LANGUAGES_CODES,
variables() {
return {
codes: [this.languageCode],
};
},
skip() {
return !this.languageCode;
},
},
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -118,6 +391,7 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
},
components: {
EmptyContent,
ActorCard,
},
})
export default class AdminUserProfile extends Vue {
@ -125,24 +399,45 @@ export default class AdminUserProfile extends Vue {
user!: IUser;
languages!: Array<{ code: string; name: string }>;
usernameWithDomain = usernameWithDomain;
RouteName = RouteName;
ICurrentUserRole = ICurrentUserRole;
isEmailChangeModalActive = false;
isRoleChangeModalActive = false;
isConfirmationModalActive = false;
newUser = {
email: "",
role: this?.user?.role,
confirm: false,
notify: true,
};
get metadata(): Array<Record<string, unknown>> {
if (!this.user) return [];
return [
{
key: this.$i18n.t("Email"),
value: this.user.email,
type: "email",
},
{
key: this.$i18n.t("Language"),
value: this.user.locale,
value: this.languages
? this.languages[0].name
: this.$i18n.t("Unknown"),
},
{
key: this.$i18n.t("Role"),
value: this.roleName(this.user.role),
type: "role",
},
{
key: this.$i18n.t("Login status"),
@ -150,26 +445,13 @@ export default class AdminUserProfile extends Vue {
? this.$i18n.t("Disabled")
: this.$t("Activated"),
},
{
key: this.$i18n.t("Profiles"),
elements: this.user.actors.map((actor: IPerson) => {
return {
link: { name: RouteName.ADMIN_PROFILE, params: { id: actor.id } },
value: actor.name
? `${actor.name} (${actor.preferredUsername})`
: actor.preferredUsername,
active: this.user.defaultActor
? actor.id === this.user.defaultActor.id
: false,
};
}),
},
{
key: this.$i18n.t("Confirmed"),
value:
this.$options.filters && this.user.confirmedAt
? this.$options.filters.formatDateTimeString(this.user.confirmedAt)
: this.$i18n.t("Not confirmed"),
type: "confirmed",
},
{
key: this.$i18n.t("Last sign-in"),
@ -183,15 +465,19 @@ export default class AdminUserProfile extends Vue {
{
key: this.$i18n.t("Last IP adress"),
value: this.user.currentSignInIp || this.$t("Unknown"),
type: "code",
type: this.user.currentSignInIp ? "ip" : undefined,
},
{
key: this.$i18n.t("Participations"),
key: this.$i18n.t("Total number of participations"),
value: this.user.participations.total,
},
{
key: this.$i18n.t("Uploaded media size"),
value: formatBytes(this.user.mediaSize),
key: this.$i18n.t("Uploaded media total size"),
value: formatBytes(
this.user.mediaSize,
2,
this.$i18n.t("0 Bytes") as string
),
},
];
}
@ -208,7 +494,16 @@ export default class AdminUserProfile extends Vue {
}
}
async deleteAccount(): Promise<Route> {
async suspendAccount(): Promise<void> {
this.$buefy.dialog.confirm({
title: this.$t("Suspend the account?") as string,
message: this.$t(
"Do you really want to suspend this account? All of the user's profiles will be deleted."
) as string,
confirmText: this.$t("Suspend the account") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
onConfirm: async () => {
await this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_USER,
variables: {
@ -216,12 +511,72 @@ export default class AdminUserProfile extends Vue {
},
});
return this.$router.push({ name: RouteName.USERS });
},
});
}
get profiles(): IActor[] {
return this.user.actors;
}
get languageCode(): string | undefined {
return this.user?.locale;
}
async confirmUser() {
this.isConfirmationModalActive = false;
await this.updateUser({
confirmed: true,
notify: this.newUser.notify,
});
}
async updateUserRole() {
this.isRoleChangeModalActive = false;
await this.updateUser({
role: this.newUser.role,
notify: this.newUser.notify,
});
}
async updateUserEmail() {
this.isEmailChangeModalActive = false;
await this.updateUser({
email: this.newUser.email,
notify: this.newUser.notify,
});
}
async updateUser(properties: {
email?: string;
notify: boolean;
confirmed?: boolean;
role?: ICurrentUserRole;
}) {
await this.$apollo.mutate<{ adminUpdateUser: IUser }>({
mutation: ADMIN_UPDATE_USER,
variables: {
id: this.id,
...properties,
},
});
}
@Watch("user")
resetCurrentUserRole(
updatedUser: IUser | undefined,
oldUser: IUser | undefined
) {
if (updatedUser?.role !== oldUser?.role) {
this.newUser.role = updatedUser?.role;
}
}
get userEmailDomain(): string | undefined {
if (this?.user?.email) {
return this?.user?.email.split("@")[1];
}
return undefined;
}
}
</script>
<style lang="scss" scoped>
table {
margin: 2rem 0;
}
</style>

View file

@ -1,19 +1,11 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ADMIN }">{{
$t("Admin")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.ADMIN_DASHBOARD }">{{
$t("Dashboard")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{ text: $t('Dashboard') },
]"
/>
<section>
<h1 class="title">{{ $t("Administration") }}</h1>
<div class="tile is-ancestor" v-if="dashboard">

View file

@ -1,116 +0,0 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ADMIN }">{{
$t("Admin")
}}</router-link>
</li>
<li>
<router-link :to="{ name: RouteName.RELAYS }">{{
$t("Federation")
}}</router-link>
</li>
<li class="is-active" v-if="$route.name == RouteName.RELAY_FOLLOWINGS">
<router-link :to="{ name: RouteName.RELAY_FOLLOWINGS }">{{
$t("Followings")
}}</router-link>
</li>
<li class="is-active" v-if="$route.name == RouteName.RELAY_FOLLOWERS">
<router-link :to="{ name: RouteName.RELAY_FOLLOWERS }">{{
$t("Followers")
}}</router-link>
</li>
</ul>
</nav>
<section>
<h1 class="title">{{ $t("Instances") }}</h1>
<div class="tabs is-boxed">
<ul>
<router-link
tag="li"
active-class="is-active"
:to="{ name: RouteName.RELAY_FOLLOWINGS }"
>
<a>
<b-icon icon="inbox-arrow-down"></b-icon>
<span>
{{ $t("Followings") }}
<b-tag rounded>{{ relayFollowings.total }}</b-tag>
</span>
</a>
</router-link>
<router-link
tag="li"
active-class="is-active"
:to="{ name: RouteName.RELAY_FOLLOWERS }"
>
<a>
<b-icon icon="inbox-arrow-up"></b-icon>
<span>
{{ $t("Followers") }}
<b-tag rounded>{{ relayFollowers.total }}</b-tag>
</span>
</a>
</router-link>
</ul>
</div>
<router-view></router-view>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { RELAY_FOLLOWERS, RELAY_FOLLOWINGS } from "@/graphql/admin";
import { Paginate } from "@/types/paginate";
import { IFollower } from "@/types/actor/follower.model";
import RouteName from "../../router/name";
@Component({
apollo: {
relayFollowings: {
query: RELAY_FOLLOWINGS,
fetchPolicy: "cache-and-network",
variables: {
page: 1,
limit: 10,
},
},
relayFollowers: {
query: RELAY_FOLLOWERS,
fetchPolicy: "cache-and-network",
variables: {
page: 1,
limit: 10,
},
},
},
metaInfo() {
return {
title: this.$t("Federation") as string,
};
},
})
export default class Follows extends Vue {
RouteName = RouteName;
activeTab = 0;
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
}
</script>
<style lang="scss" scoped>
.tab-item {
form {
margin-bottom: 1.5rem;
}
}
a {
text-decoration: none !important;
}
</style>

View file

@ -1,19 +1,14 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MODERATION }">{{
$t("Moderation")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.PROFILES }">{{
$t("Groups")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{ name: RouteName.MODERATION, text: $t('Moderation') },
{
name: RouteName.ADMIN_GROUPS,
text: $t('Groups'),
},
]"
/>
<div class="buttons" v-if="showCreateGroupsButton">
<router-link
class="button is-primary"

View file

@ -0,0 +1,246 @@
<template>
<div v-if="instance">
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{ name: RouteName.INSTANCES, text: $t('Instances') },
{ text: instance.domain },
]"
/>
<h1 class="text-2xl">{{ instance.domain }}</h1>
<div class="grid md:grid-cols-4 gap-2 content-center text-center mt-2">
<div class="bg-gray-50 rounded-xl p-8">
<router-link
:to="{
name: RouteName.PROFILES,
query: { domain: instance.domain },
}"
>
<span class="mb-4 text-xl font-semibold block">{{
instance.personCount
}}</span>
<span class="text-sm block">{{ $t("Profiles") }}</span>
</router-link>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<router-link
:to="{
name: RouteName.ADMIN_GROUPS,
query: { domain: instance.domain },
}"
>
<span class="mb-4 text-xl font-semibold block">{{
instance.groupCount
}}</span>
<span class="text-sm block">{{ $t("Groups") }}</span>
</router-link>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<span class="mb-4 text-xl font-semibold block">{{
instance.followingsCount
}}</span>
<span class="text-sm block">{{ $t("Followings") }}</span>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<span class="mb-4 text-xl font-semibold block">{{
instance.followersCount
}}</span>
<span class="text-sm block">{{ $t("Followers") }}</span>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<router-link
:to="{ name: RouteName.REPORTS, query: { domain: instance.domain } }"
>
<span class="mb-4 text-xl font-semibold block">{{
instance.reportsCount
}}</span>
<span class="text-sm block">{{ $t("Reports") }}</span>
</router-link>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<span class="mb-4 font-semibold block">{{
formatBytes(instance.mediaSize)
}}</span>
<span class="text-sm block">{{ $t("Uploaded media size") }}</span>
</div>
</div>
<div class="mt-3 grid md:grid-cols-2 gap-4" v-if="instance.hasRelay">
<div class="border bg-white p-6 shadow-md rounded-md">
<button
@click="removeInstanceFollow"
v-if="instance.followedStatus == InstanceFollowStatus.APPROVED"
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
>
{{ $t("Stop following instance") }}
</button>
<button
@click="removeInstanceFollow"
v-else-if="instance.followedStatus == InstanceFollowStatus.PENDING"
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
>
{{ $t("Cancel follow request") }}
</button>
<button
@click="followInstance"
v-else
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
>
{{ $t("Follow instance") }}
</button>
</div>
<div class="border bg-white p-6 shadow-md rounded-md">
<button
@click="acceptInstance"
v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
class="bg-green-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
>
{{ $t("Accept follow") }}
</button>
<button
@click="rejectInstance"
v-else-if="instance.followerStatus != InstanceFollowStatus.NONE"
class="bg-red-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
>
{{ $t("Reject follow") }}
</button>
<p v-else>
{{ $t("This instance doesn't follow yours.") }}
</p>
</div>
</div>
<div v-else class="md:h-48 py-16 text-center opacity-50">
{{ $t("Only Mobilizon instances can be followed") }}
</div>
</div>
</template>
<script lang="ts">
import {
ACCEPT_RELAY,
ADD_INSTANCE,
INSTANCE,
REJECT_RELAY,
REMOVE_RELAY,
} from "@/graphql/admin";
import { Component, Prop, Vue } from "vue-property-decorator";
import { formatBytes } from "@/utils/datetime";
import RouteName from "@/router/name";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { IInstance } from "@/types/instance.model";
import { ApolloCache, gql, Reference } from "@apollo/client/core";
import { InstanceFollowStatus } from "@/types/enums";
@Component({
apollo: {
instance: {
query: INSTANCE,
variables() {
return {
domain: this.domain,
};
},
},
},
})
export default class Instance extends Vue {
@Prop({ type: String, required: true }) domain!: string;
instance!: IInstance;
InstanceFollowStatus = InstanceFollowStatus;
formatBytes = formatBytes;
RouteName = RouteName;
async acceptInstance(): Promise<void> {
try {
await this.$apollo.mutate({
mutation: ACCEPT_RELAY,
variables: {
address: `relay@${this.domain}`,
},
});
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
async rejectInstance(): Promise<void> {
try {
await this.$apollo.mutate({
mutation: REJECT_RELAY,
variables: {
address: `relay@${this.domain}`,
},
});
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
async followInstance(e: Event): Promise<void> {
e.preventDefault();
try {
await this.$apollo.mutate<{ addInstance: Instance }>({
mutation: ADD_INSTANCE,
variables: {
domain: this.domain,
},
});
} catch (err: any) {
if (err.message) {
Snackbar.open({
message: err.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
async removeInstanceFollow(): Promise<void> {
const { instance } = this;
try {
await this.$apollo.mutate({
mutation: REMOVE_RELAY,
variables: {
address: `relay@${this.domain}`,
},
update(cache: ApolloCache<any>) {
cache.writeFragment({
id: cache.identify(instance as unknown as Reference),
fragment: gql`
fragment InstanceFollowedStatus on Instance {
followedStatus
}
`,
data: {
followedStatus: InstanceFollowStatus.NONE,
},
});
},
});
} catch (e: any) {
if (e.message) {
Snackbar.open({
message: e.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
}
</script>

View file

@ -0,0 +1,293 @@
<template>
<div>
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{ text: $t('Instances') },
]"
/>
<section>
<h1 class="title">{{ $t("Instances") }}</h1>
<form @submit="followInstance" class="my-4">
<b-field :label="$t('Follow a new instance')" horizontal>
<b-field grouped group-multiline expanded size="is-large">
<p class="control">
<b-input
v-model="newRelayAddress"
:placeholder="$t('Ex: mobilizon.fr')"
/>
</p>
<p class="control">
<b-button type="is-primary" native-type="submit">{{
$t("Add an instance")
}}</b-button>
</p>
</b-field>
</b-field>
</form>
<div class="flex flex-wrap gap-2">
<b-field :label="$t('Follow status')">
<b-radio-button
v-model="followStatus"
:native-value="InstanceFilterFollowStatus.ALL"
>{{ $t("All") }}</b-radio-button
>
<b-radio-button
v-model="followStatus"
:native-value="InstanceFilterFollowStatus.FOLLOWING"
>{{ $t("Following") }}</b-radio-button
>
<b-radio-button
v-model="followStatus"
:native-value="InstanceFilterFollowStatus.FOLLOWED"
>{{ $t("Followed") }}</b-radio-button
>
</b-field>
<b-field
:label="$t('Domain')"
label-for="domain-filter"
class="flex-auto"
>
<b-input
id="domain-filter"
:placeholder="$t('mobilizon-instance.tld')"
:value="filterDomain"
@input="debouncedUpdateDomainFilter"
/>
</b-field>
</div>
<div v-if="instances && instances.elements.length > 0" class="mt-3">
<router-link
:to="{
name: RouteName.INSTANCE,
params: { domain: instance.domain },
}"
class="flex items-center mb-2 rounded bg-secondary p-4 flex-wrap justify-center gap-x-2 gap-y-3"
v-for="instance in instances.elements"
:key="instance.domain"
>
<div class="grow overflow-hidden flex items-center gap-1">
<img
class="w-12"
v-if="instance.hasRelay"
src="../../assets/logo.svg"
alt=""
/>
<b-icon
class="is-large"
v-else
custom-size="mdi-36px"
icon="cloud-question"
/>
<div class="">
<h4 class="text-lg truncate">{{ instance.domain }}</h4>
<span
class="text-sm"
v-if="instance.followedStatus === InstanceFollowStatus.APPROVED"
>
<b-icon icon="inbox-arrow-down" />
{{ $t("Followed") }}</span
>
<span
class="text-sm"
v-else-if="
instance.followedStatus === InstanceFollowStatus.PENDING
"
>
<b-icon icon="inbox-arrow-down" />
{{ $t("Followed, pending response") }}</span
>
<span
class="text-sm"
v-if="instance.followerStatus == InstanceFollowStatus.APPROVED"
>
<b-icon icon="inbox-arrow-up" />
{{ $t("Follows us") }}</span
>
<span
class="text-sm"
v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
>
<b-icon icon="inbox-arrow-up" />
{{ $t("Follows us, pending approval") }}</span
>
</div>
</div>
<div class="flex-none flex gap-3 ltr:ml-3 rtl:mr-3">
<p class="flex flex-col text-center">
<span class="text-xl">{{ instance.eventCount }}</span
><span class="text-sm">{{ $t("Events") }}</span>
</p>
<p class="flex flex-col text-center">
<span class="text-xl">{{ instance.personCount }}</span
><span class="text-sm">{{ $t("Profiles") }}</span>
</p>
</div>
</router-link>
</div>
<div v-else-if="instances && instances.elements.length == 0">
<empty-content icon="lan-disconnect" :inline="true">
{{ $t("No instance found.") }}
<template #desc>
<span v-if="hasFilter">
{{
$t(
"No instances match this filter. Try resetting filter fields?"
)
}}
</span>
<span v-else>
{{ $t("You haven't interacted with other instances yet.") }}
</span>
</template>
</empty-content>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { ADD_INSTANCE, INSTANCES } from "@/graphql/admin";
import { Paginate } from "@/types/paginate";
import { IFollower } from "@/types/actor/follower.model";
import RouteName from "../../router/name";
import { IInstance } from "@/types/instance.model";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import VueRouter from "vue-router";
import { debounce } from "lodash";
import {
InstanceFilterFollowStatus,
InstanceFollowStatus,
} from "@/types/enums";
import { SnackbarProgrammatic as Snackbar } from "buefy";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
@Component({
apollo: {
instances: {
query: INSTANCES,
fetchPolicy: "cache-and-network",
variables() {
return {
page: this.instancePage,
limit: 10,
filterDomain: this.filterDomain,
filterFollowStatus: this.followStatus,
};
},
},
},
metaInfo() {
return {
title: this.$t("Federation") as string,
};
},
components: {
EmptyContent,
},
})
export default class Follows extends Vue {
RouteName = RouteName;
newRelayAddress = "";
instances!: Paginate<IInstance>;
instancePage = 1;
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
InstanceFilterFollowStatus = InstanceFilterFollowStatus;
InstanceFollowStatus = InstanceFollowStatus;
data(): Record<string, unknown> {
return {
debouncedUpdateDomainFilter: debounce(this.updateDomainFilter, 500),
};
}
updateDomainFilter(domain: string) {
this.filterDomain = domain;
}
get filterDomain(): string {
return (this.$route.query.domain as string) || "";
}
set filterDomain(domain: string) {
this.pushRouter({ domain });
}
get followStatus(): InstanceFilterFollowStatus {
return (
(this.$route.query.followStatus as InstanceFilterFollowStatus) ||
InstanceFilterFollowStatus.ALL
);
}
set followStatus(followStatus: InstanceFilterFollowStatus) {
this.pushRouter({ followStatus });
}
get hasFilter(): boolean {
return (
this.followStatus !== InstanceFilterFollowStatus.ALL ||
this.filterDomain !== ""
);
}
async followInstance(e: Event): Promise<void> {
e.preventDefault();
const domain = this.newRelayAddress.trim(); // trim to fix copy and paste domain name spaces and tabs
try {
await this.$apollo.mutate<{ relayFollowings: Paginate<IFollower> }>({
mutation: ADD_INSTANCE,
variables: {
domain,
},
});
this.newRelayAddress = "";
this.$router.push({
name: RouteName.INSTANCE,
params: { domain },
});
} catch (err: any) {
if (err.message) {
Snackbar.open({
message: err.message,
type: "is-danger",
position: "is-bottom",
});
}
}
}
private async pushRouter(args: Record<string, string>): Promise<void> {
try {
await this.$router.push({
name: RouteName.INSTANCES,
query: { ...this.$route.query, ...args },
});
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
throw Error(e.toString());
}
}
}
}
</script>
<style lang="scss" scoped>
.tab-item {
form {
margin-bottom: 1.5rem;
}
}
a {
text-decoration: none !important;
}
</style>

View file

@ -1,19 +1,14 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MODERATION }">{{
$t("Moderation")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.PROFILES }">{{
$t("Profiles")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{ name: RouteName.MODERATION, text: $t('Moderation') },
{
name: RouteName.PROFILES,
text: $t('Profiles'),
},
]"
/>
<div v-if="persons">
<b-switch v-model="local">{{ $t("Local") }}</b-switch>
<b-switch v-model="suspended">{{ $t("Suspended") }}</b-switch>

View file

@ -1,19 +1,12 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ADMIN }">{{
$t("Admin")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.ADMIN_SETTINGS }">{{
$t("Instance settings")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{ name: RouteName.ADMIN, text: $t('Admin') },
{ text: $t('Instance settings') },
]"
/>
<section v-if="settingsToWrite">
<form @submit.prevent="updateSettings">
<b-field :label="$t('Instance Name')" label-for="instance-name">

View file

@ -1,27 +1,36 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MODERATION }">{{
$t("Moderation")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.USERS }">{{
$t("Users")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{ name: RouteName.MODERATION, text: $t('Moderation') },
{
name: RouteName.USERS,
text: $t('Users'),
},
]"
/>
<div v-if="users">
<form @submit.prevent="activateFilters">
<b-field class="mb-5" grouped group-multiline>
<b-field :label="$t('Email')" expanded>
<b-input trap-focus icon="email" v-model="emailFilterFieldValue" />
</b-field>
<b-field :label="$t('IP Address')" expanded>
<b-input icon="web" v-model="ipFilterFieldValue" />
</b-field>
<p class="control self-end mb-0">
<b-button type="is-primary" native-type="submit">{{
$t("Filter")
}}</b-button>
</p>
</b-field>
</form>
<b-table
:data="users.elements"
:loading="$apollo.queries.users.loading"
paginated
backend-pagination
backend-filtering
detailed
:debounce-search="500"
:current-page.sync="page"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
@ -30,25 +39,14 @@
:show-detail-icon="true"
:total="users.total"
:per-page="USERS_PER_PAGE"
:has-detailed-visible="(row) => row.actors.length > 0"
@page-change="onPageChange"
@filters-change="onFiltersChange"
>
<b-table-column field="id" width="40" numeric v-slot="props">
{{ props.row.id }}
</b-table-column>
<b-table-column field="email" :label="$t('Email')" searchable>
<template #searchable="props">
<b-input
v-model="props.filters.email"
:aria-label="$t('Filter')"
:placeholder="$t('Filter')"
icon="magnify"
/>
</template>
<b-table-column field="email" :label="$t('Email')">
<template v-slot:default="props">
<router-link
class="user-profile"
:to="{
name: RouteName.ADMIN_USER_PROFILE,
params: { id: props.row.id },
@ -61,13 +59,16 @@
</b-table-column>
<b-table-column
field="confirmedAt"
:label="$t('Confirmed at')"
:label="$t('Last seen on')"
:centered="true"
v-slot="props"
>
<template v-if="props.row.confirmedAt">
{{ props.row.confirmedAt | formatDateTimeString }}
<template v-if="props.row.currentSignInAt">
<time :datetime="props.row.currentSignInAt">
{{ props.row.currentSignInAt | formatDateTimeString }}
</time>
</template>
<template v-else-if="props.row.confirmedAt"> - </template>
<template v-else>
{{ $t("Not confirmed") }}
</template>
@ -80,30 +81,19 @@
>
{{ getLanguageNameForCode(props.row.locale) }}
</b-table-column>
<template #detail="props">
<router-link
class="profile"
v-for="actor in props.row.actors"
:key="actor.id"
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: actor.id } }"
<template #empty>
<empty-content
v-if="!$apollo.loading && emailFilter"
:inline="true"
icon="account"
>
<article class="media">
<figure class="media-left">
<p class="image is-32x32" v-if="actor.avatar">
<img :src="actor.avatar.url" />
</p>
<b-icon v-else size="is-medium" icon="account-circle" />
</figure>
<div class="media-content">
<div class="content">
<strong v-if="actor.name">{{ actor.name }}</strong>
<small>@{{ actor.preferredUsername }}</small>
<p>{{ actor.summary }}</p>
</div>
</div>
</article>
</router-link>
{{ $t("No user matches the filters") }}
<template #desc>
<b-button type="is-primary" @click="resetFilters">
{{ $t("Reset filters") }}
</b-button>
</template>
</empty-content>
</template>
</b-table>
</div>
@ -117,6 +107,7 @@ import VueRouter from "vue-router";
import { LANGUAGES_CODES } from "@/graphql/admin";
import { IUser } from "@/types/current-user.model";
import { Paginate } from "@/types/paginate";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
const USERS_PER_PAGE = 10;
@ -128,7 +119,8 @@ const USERS_PER_PAGE = 10;
fetchPolicy: "cache-and-network",
variables() {
return {
email: this.email,
email: this.emailFilter,
currentSignInIp: this.ipFilter,
page: this.page,
limit: USERS_PER_PAGE,
};
@ -151,6 +143,9 @@ const USERS_PER_PAGE = 10;
title: this.$t("Users") as string,
};
},
components: {
EmptyContent,
},
})
export default class Users extends Vue {
USERS_PER_PAGE = USERS_PER_PAGE;
@ -160,6 +155,9 @@ export default class Users extends Vue {
users!: Paginate<IUser>;
languages!: Array<{ code: string; name: string }>;
emailFilterFieldValue = this.emailFilter;
ipFilterFieldValue = this.ipFilter;
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
@ -168,12 +166,20 @@ export default class Users extends Vue {
this.pushRouter({ page: page.toString() });
}
get email(): string {
return (this.$route.query.email as string) || "";
get emailFilter(): string {
return (this.$route.query.emailFilter as string) || "";
}
set email(email: string) {
this.pushRouter({ email });
set emailFilter(emailFilter: string) {
this.pushRouter({ emailFilter });
}
get ipFilter(): string {
return (this.$route.query.ipFilter as string) || "";
}
set ipFilter(ipFilter: string) {
this.pushRouter({ ipFilter });
}
get languagesCodes(): string[] {
@ -192,15 +198,23 @@ export default class Users extends Vue {
this.page = page;
await this.$apollo.queries.users.fetchMore({
variables: {
email: this.email,
email: this.emailFilter,
currentSignInIp: this.ipFilter,
page: this.page,
limit: USERS_PER_PAGE,
},
});
}
onFiltersChange({ email }: { email: string }): void {
this.email = email;
activateFilters(): void {
this.emailFilter = this.emailFilterFieldValue;
this.ipFilter = this.ipFilterFieldValue;
}
resetFilters(): void {
this.emailFilterFieldValue = "";
this.ipFilterFieldValue = "";
this.activateFilters();
}
private async pushRouter(args: Record<string, string>): Promise<void> {

View file

@ -1,42 +1,29 @@
<template>
<section class="section container">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul v-if="group">
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{
$t("My groups")
}}</router-link>
</li>
<li>
<router-link
:to="{
<breadcrumbs-nav
v-if="group"
:links="[
{
name: RouteName.MY_GROUPS,
text: $t('My groups'),
},
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li>
<router-link
:to="{
text: displayName(group),
},
{
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Discussions") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: $t('Discussions'),
},
{
name: RouteName.CREATE_DISCUSSION,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Create") }}</router-link
>
</li>
</ul>
<b-skeleton v-else-if="$apollo.loading" :animated="animated"></b-skeleton>
</nav>
text: $t('Create'),
},
]"
/>
<h1 class="title">{{ $t("Create a discussion") }}</h1>
<form @submit.prevent="createDiscussion">
@ -67,7 +54,12 @@
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IPerson, usernameWithDomain } from "@/types/actor";
import {
displayName,
IGroup,
IPerson,
usernameWithDomain,
} from "@/types/actor";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
import { CREATE_DISCUSSION } from "@/graphql/discussion";
@ -113,6 +105,8 @@ export default class CreateDiscussion extends Vue {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
async createDiscussion(): Promise<void> {
this.errors = { title: "" };
try {

View file

@ -1,46 +1,29 @@
<template>
<div class="container section" v-if="discussion">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{
$t("My groups")
}}</router-link>
</li>
<li>
<router-link
v-if="discussion.actor"
:to="{
<breadcrumbs-nav
v-if="group"
:links="[
{
name: RouteName.MY_GROUPS,
text: $t('My groups'),
},
{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(discussion.actor),
params: { preferredUsername: usernameWithDomain(group) },
text: displayName(group),
},
}"
>{{ discussion.actor.name }}</router-link
>
<b-skeleton v-else-if="$apollo.loading" animated />
</li>
<li>
<router-link
v-if="discussion.actor"
:to="{
{
name: RouteName.DISCUSSION_LIST,
params: {
preferredUsername: usernameWithDomain(discussion.actor),
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Discussions'),
},
}"
>{{ $t("Discussions") }}</router-link
>
<b-skeleton animated v-else-if="$apollo.loading" />
</li>
<li class="is-active">
<router-link
:to="{ name: RouteName.DISCUSSION, params: { id: discussion.id } }"
>{{ discussion.title }}</router-link
>
</li>
</ul>
</nav>
{
name: RouteName.DISCUSSION,
params: { id: discussion.id },
text: discussion.title,
},
]"
/>
<b-message v-if="error" type="is-danger">
{{ error }}
</b-message>
@ -148,7 +131,7 @@ import {
} from "@/graphql/discussion";
import { IDiscussion } from "@/types/discussions";
import { Discussion as DiscussionModel } from "@/types/discussions";
import { usernameWithDomain } from "@/types/actor";
import { displayName, usernameWithDomain } from "@/types/actor";
import DiscussionComment from "@/components/Discussion/DiscussionComment.vue";
import { GraphQLError } from "graphql";
import { DELETE_COMMENT, UPDATE_COMMENT } from "@/graphql/comment";
@ -250,6 +233,7 @@ export default class Discussion extends mixins(GroupMixin) {
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
error: string | null = null;
async reply(): Promise<void> {

View file

@ -1,32 +1,23 @@
<template>
<div class="container section" v-if="group">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{
$t("My groups")
}}</router-link>
</li>
<li>
<router-link
:to="{
<breadcrumbs-nav
:links="[
{
name: RouteName.MY_GROUPS,
text: $t('My groups'),
},
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: displayName(group),
},
{
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Discussions") }}</router-link
>
</li>
</ul>
</nav>
text: $t('Discussions'),
},
]"
/>
<section v-if="isCurrentActorAGroupMember">
<p>
{{
@ -82,7 +73,13 @@
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { FETCH_GROUP } from "@/graphql/group";
import { IActor, IGroup, IPerson, usernameWithDomain } from "@/types/actor";
import {
displayName,
IActor,
IGroup,
IPerson,
usernameWithDomain,
} from "@/types/actor";
import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
import RouteName from "../../router/name";
import { MemberRole } from "@/types/enums";
@ -166,6 +163,7 @@ export default class DiscussionsList extends Vue {
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
DISCUSSIONS_PER_PAGE = DISCUSSIONS_PER_PAGE;

View file

@ -1,27 +1,19 @@
<template>
<div class="container section" v-if="group">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
:to="{
<breadcrumbs-nav
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.TODO_LISTS,
text: displayName(group),
},
{
name: RouteName.EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Events") }}</router-link
>
</li>
</ul>
</nav>
text: $t('Events'),
},
]"
/>
<section>
<h1 class="title" v-if="group">
{{
@ -89,7 +81,7 @@ import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import GroupMixin from "@/mixins/group";
import { IMember } from "@/types/actor/member.model";
import { FETCH_GROUP_EVENTS } from "@/graphql/event";
import { usernameWithDomain } from "../../types/actor";
import { displayName, usernameWithDomain } from "../../types/actor";
const EVENTS_PAGE_LIMIT = 10;
@ -143,6 +135,8 @@ export default class GroupEvents extends mixins(GroupMixin) {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
RouteName = RouteName;
EVENTS_PAGE_LIMIT = EVENTS_PAGE_LIMIT;

View file

@ -1,32 +1,20 @@
<template>
<section class="section container" v-if="event">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_EVENTS }">{{
$t("My events")
}}</router-link>
</li>
<li>
<router-link
:to="{
<breadcrumbs-nav
:links="[
{ name: RouteName.MY_EVENTS, text: $t('My events') },
{
name: RouteName.EVENT,
params: { uuid: event.uuid },
}"
>{{ event.title }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: event.title,
},
{
name: RouteName.PARTICIPANTS,
params: { uuid: event.uuid },
}"
>{{ $t("Participants") }}</router-link
>
</li>
</ul>
</nav>
text: $t('Participants'),
},
]"
/>
<h1 class="title">{{ $t("Participants") }}</h1>
<div class="level">
<div class="level-left">

View file

@ -1,27 +1,17 @@
<template>
<div class="container is-widescreen">
<div class="header">
<nav class="breadcrumb" :aria-label="$t('Breadcrumbs')">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{
$t("My groups")
}}</router-link>
</li>
<li class="is-active">
<router-link
aria-current-value="location"
v-if="group && group.preferredUsername"
:to="{
<breadcrumbs-nav
v-if="group"
:links="[
{ name: RouteName.MY_GROUPS, text: $t('My groups') },
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
</ul>
</nav>
text: displayName(group),
},
]"
/>
<b-loading :active.sync="$apollo.loading"></b-loading>
<header class="block-container presentation" v-if="group">
<div class="banner-container">
@ -776,6 +766,8 @@ export default class Group extends mixins(GroupMixin) {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
PostVisibility = PostVisibility;
Openness = Openness;

View file

@ -1,36 +1,25 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul v-if="group">
<li>
<router-link
:to="{
<breadcrumbs-nav
v-if="group"
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li>
<router-link
:to="{
text: displayName(group),
},
{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Settings") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: $t('Settings'),
},
{
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Followers") }}</router-link
>
</li>
</ul>
</nav>
text: $t('Followers'),
},
]"
/>
<b-loading :active="$apollo.loading" />
<section
class="container section"
@ -138,7 +127,7 @@ import GroupMixin from "@/mixins/group";
import { mixins } from "vue-class-component";
import { GROUP_FOLLOWERS, UPDATE_FOLLOWER } from "@/graphql/followers";
import RouteName from "../../router/name";
import { usernameWithDomain } from "../../types/actor";
import { displayName, usernameWithDomain } from "../../types/actor";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { IFollower } from "@/types/actor/follower.model";
import { Paginate } from "@/types/paginate";
@ -181,6 +170,8 @@ export default class GroupFollowers extends mixins(GroupMixin) {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
followers!: Paginate<IFollower>;
mounted(): void {

View file

@ -1,36 +1,25 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul v-if="group">
<li>
<router-link
:to="{
<breadcrumbs-nav
v-if="group"
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li>
<router-link
:to="{
text: displayName(group),
},
{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Settings") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: $t('Settings'),
},
{
name: RouteName.GROUP_MEMBERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Members") }}</router-link
>
</li>
</ul>
</nav>
text: $t('Members'),
},
]"
/>
<b-loading :active="$apollo.loading" />
<section
class="container section"
@ -312,6 +301,8 @@ export default class GroupMembers extends mixins(GroupMixin) {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
mounted(): void {
const roleQuery = this.$route.query.role as string;
if (Object.values(MemberRole).includes(roleQuery as MemberRole)) {

View file

@ -1,37 +1,25 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
<breadcrumbs-nav
v-if="group"
:to="{
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name || usernameWithDomain(group) }}</router-link
>
</li>
<li>
<router-link
:to="{
text: displayName(group),
},
{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Settings") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: $t('Settings'),
},
{
name: RouteName.GROUP_PUBLIC_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Group settings") }}</router-link
>
</li>
</ul>
</nav>
text: $t('Group settings'),
},
]"
/>
<b-loading :active="$apollo.loading" />
<section
class="container section"
@ -197,7 +185,12 @@ import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import { GroupVisibility, Openness } from "@/types/enums";
import { UPDATE_GROUP } from "../../graphql/group";
import { Group, IGroup, usernameWithDomain } from "../../types/actor";
import {
Group,
IGroup,
usernameWithDomain,
displayName,
} from "../../types/actor";
import { Address, IAddress } from "../../types/address.model";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
@ -234,6 +227,8 @@ export default class GroupSettings extends mixins(GroupMixin) {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
GroupVisibility = GroupVisibility;
Openness = Openness;

View file

@ -1,27 +1,21 @@
<template>
<div class="container section">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul v-if="group">
<li>
<router-link
:to="{
<breadcrumbs-nav
v-if="group"
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li>
<router-link
:to="{
text: displayName(group),
},
{
name: RouteName.TIMELINE,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Activity") }}</router-link
>
</li>
</ul>
</nav>
text: $t('Activity'),
},
]"
/>
<section class="timeline">
<b-field>
<b-radio-button v-model="activityType" :native-value="undefined">
@ -160,7 +154,7 @@
</template>
<script lang="ts">
import { GROUP_TIMELINE } from "@/graphql/group";
import { IGroup, usernameWithDomain } from "@/types/actor";
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
import { ActivityType } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { Component, Prop, Vue } from "vue-property-decorator";
@ -234,6 +228,8 @@ export default class Timeline extends Vue {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
ActivityType = ActivityType;
ActivityAuthorFilter = ActivityAuthorFilter;

View file

@ -1,19 +1,17 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MODERATION }">{{
$t("Moderation")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.REPORT_LOGS }">{{
$t("Moderation log")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{
name: RouteName.MODERATION,
text: $t('Moderation'),
},
{
name: RouteName.REPORT_LOGS,
text: $t('Moderation log'),
},
]"
/>
<section v-if="actionLogs.total > 0 && actionLogs.elements.length > 0">
<ul>
<li v-for="log in actionLogs.elements" :key="log.id">

View file

@ -1,27 +1,23 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="report">
<ul>
<li>
<router-link :to="{ name: RouteName.MODERATION }">{{
$t("Moderation")
}}</router-link>
</li>
<li>
<router-link :to="{ name: RouteName.REPORTS }">{{
$t("Reports")
}}</router-link>
</li>
<li class="is-active">
<router-link
:to="{ name: RouteName.REPORT, params: { id: report.id } }"
>{{
$t("Report #{reportNumber}", { reportNumber: report.id })
}}</router-link
>
</li>
</ul>
</nav>
<breadcrumbs-nav
v-if="report"
:links="[
{
name: RouteName.MODERATION,
text: $t('Moderation'),
},
{
name: RouteName.REPORTS,
text: $t('Reports'),
},
{
name: RouteName.REPORT,
params: { id: report.id },
text: $t('Report #{reportNumber}', { reportNumber: report.id }),
},
]"
/>
<section>
<b-message
title="Error"

View file

@ -1,21 +1,20 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MODERATION }">{{
$t("Moderation")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.REPORTS }">{{
$t("Reports")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{
name: RouteName.MODERATION,
text: $t('Moderation'),
},
{
name: RouteName.REPORTS,
text: $t('Reports'),
},
]"
/>
<section>
<b-field>
<div class="flex flex-wrap gap-2">
<b-field :label="$t('Report status')">
<b-radio-button
v-model="status"
:native-value="ReportStatusEnum.OPEN"
@ -32,6 +31,19 @@
>{{ $t("Closed") }}</b-radio-button
>
</b-field>
<b-field
:label="$t('Domain')"
label-for="domain-filter"
class="flex-auto"
>
<b-input
id="domain-filter"
:placeholder="$t('mobilizon-instance.tld')"
:value="filterDomain"
@input="debouncedUpdateDomainFilter"
/>
</b-field>
</div>
<ul v-if="reports.elements.length > 0">
<li v-for="report in reports.elements" :key="report.id">
<router-link
@ -88,6 +100,7 @@ import { ReportStatusEnum } from "@/types/enums";
import RouteName from "../../router/name";
import VueRouter from "vue-router";
import { Paginate } from "@/types/paginate";
import debounce from "lodash/debounce";
const { isNavigationFailure, NavigationFailureType } = VueRouter;
const REPORT_PAGE_LIMIT = 10;
@ -106,6 +119,7 @@ const REPORT_PAGE_LIMIT = 10;
page: this.page,
status: this.status,
limit: REPORT_PAGE_LIMIT,
domain: this.filterDomain,
};
},
pollInterval: 120000, // 2 minutes
@ -128,18 +142,28 @@ export default class ReportList extends Vue {
REPORT_PAGE_LIMIT = REPORT_PAGE_LIMIT;
data(): Record<string, unknown> {
return {
debouncedUpdateDomainFilter: debounce(this.updateDomainFilter, 500),
};
}
async updateDomainFilter(domain: string) {
this.filterDomain = domain;
}
get page(): number {
return parseInt((this.$route.query.page as string) || "1", 10);
}
set page(page: number) {
this.pushRouter(RouteName.REPORTS, {
this.pushRouter({
page: page.toString(),
});
}
get status(): ReportStatusEnum {
const filter = this.$route.params.filter?.toUpperCase();
const filter = (this.$route.query.status || "") as string;
if (filter in ReportStatusEnum) {
return filter as ReportStatusEnum;
}
@ -147,19 +171,21 @@ export default class ReportList extends Vue {
}
set status(status: ReportStatusEnum) {
this.$router.push({
name: RouteName.REPORTS,
params: { filter: status.toLowerCase() },
});
this.pushRouter({ status });
}
protected async pushRouter(
routeName: string,
args: Record<string, string>
): Promise<void> {
get filterDomain(): string {
return (this.$route.query.domain as string) || "";
}
set filterDomain(domain: string) {
this.pushRouter({ domain });
}
protected async pushRouter(args: Record<string, string>): Promise<void> {
try {
await this.$router.push({
name: routeName,
name: RouteName.REPORTS,
params: this.$route.params,
query: { ...this.$route.query, ...args },
});

View file

@ -2,43 +2,7 @@
<div>
<form @submit.prevent="publish(false)" v-if="isCurrentActorAGroupModerator">
<div class="container section">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="actualGroup">
<ul>
<li>
<router-link
v-if="actualGroup"
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(actualGroup),
},
}"
>{{
actualGroup.name || actualGroup.preferredUsername
}}</router-link
>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
<li>
<router-link
v-if="actualGroup"
:to="{
name: RouteName.POSTS,
params: {
preferredUsername: usernameWithDomain(actualGroup),
},
}"
>{{ $t("Posts") }}</router-link
>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
<li class="is-active">
<span v-if="preferredUsername">{{ $t("New post") }}</span>
<span v-else-if="slug">{{ $t("Edit post") }}</span>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
</ul>
</nav>
<breadcrumbs-nav v-if="actualGroup" :links="breadcrumbLinks" />
<h1 class="title" v-if="isUpdate === true">
{{ $t("Edit post") }}
</h1>
@ -174,7 +138,7 @@ import { CREATE_POST, UPDATE_POST } from "../../graphql/post";
import { IPost } from "../../types/post.model";
import Editor from "../../components/Editor.vue";
import { IActor, usernameWithDomain } from "../../types/actor";
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import TagInput from "../../components/Event/TagInput.vue";
import RouteName from "../../router/name";
import Subtitle from "../../components/Utils/Subtitle.vue";
@ -366,6 +330,39 @@ export default class EditPost extends mixins(GroupMixin, PostMixin) {
}
return this.group;
}
get breadcrumbLinks() {
const links = [
{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(this.actualGroup),
},
text: displayName(this.actualGroup),
},
{
name: RouteName.POSTS,
params: {
preferredUsername: usernameWithDomain(this.actualGroup),
},
text: this.$t("Posts"),
},
];
if (this.preferredUsername) {
links.push({
text: this.$t("New post") as string,
name: RouteName.POST_EDIT,
params: { preferredUsername: usernameWithDomain(this.actualGroup) },
});
} else {
links.push({
text: this.$t("Edit post") as string,
name: RouteName.POST_EDIT,
params: { preferredUsername: usernameWithDomain(this.actualGroup) },
});
}
return links;
}
}
</script>
<style lang="scss" scoped>

View file

@ -1,31 +1,19 @@
<template>
<div class="container section" v-if="group">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group">
<ul>
<li>
<router-link
v-if="group"
:to="{
<breadcrumbs-nav
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name || group.preferredUsername }}</router-link
>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
<li class="is-active">
<router-link
v-if="group"
:to="{
text: displayName(group),
},
{
name: RouteName.POSTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Posts") }}</router-link
>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
</ul>
</nav>
text: $t('Posts'),
},
]"
/>
<section>
<div class="intro">
<p v-if="isCurrentActorMember">
@ -84,7 +72,7 @@ import { IMember } from "@/types/actor/member.model";
import { FETCH_GROUP_POSTS } from "../../graphql/post";
import { Paginate } from "../../types/paginate";
import { IPost } from "../../types/post.model";
import { usernameWithDomain } from "../../types/actor";
import { usernameWithDomain, displayName } from "../../types/actor";
import RouteName from "../../router/name";
import MultiPostListItem from "../../components/Post/MultiPostListItem.vue";
@ -148,6 +136,8 @@ export default class PostList extends mixins(GroupMixin) {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
POSTS_PAGE_LIMIT = POSTS_PAGE_LIMIT;
get isCurrentActorMember(): boolean {

View file

@ -1,47 +1,6 @@
<template>
<div class="container section" v-if="resource">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(resource.actor) },
}"
>{{ resource.actor.name }}</router-link
>
</li>
<li>
<router-link
:to="{
name: RouteName.RESOURCE_FOLDER_ROOT,
params: { preferredUsername: usernameWithDomain(resource.actor) },
}"
>{{ $t("Resources") }}</router-link
>
</li>
<li
:class="{
'is-active':
index + 1 === ResourceMixin.resourcePathArray(resource).length,
}"
v-for="(pathFragment, index) in filteredPath"
:key="pathFragment"
>
<router-link
:to="{
name: RouteName.RESOURCE_FOLDER,
params: {
path: ResourceMixin.resourcePathArray(resource).slice(
0,
index + 1
),
preferredUsername: usernameWithDomain(resource.actor),
},
}"
>{{ pathFragment }}</router-link
>
</li>
<breadcrumbs-nav :links="breadcrumbLinks">
<li>
<b-dropdown aria-role="list">
<b-button class="button is-primary" slot="trigger">+</b-button>
@ -73,8 +32,7 @@
</b-dropdown-item>
</b-dropdown>
</li>
</ul>
</nav>
</breadcrumbs-nav>
<section>
<p v-if="resource.path === '/'" class="module-description">
{{
@ -276,7 +234,7 @@ import ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue";
import Draggable from "vuedraggable";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IActor, usernameWithDomain } from "../../types/actor";
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import {
IResource,
@ -764,6 +722,40 @@ export default class Resources extends Mixins(ResourceMixin) {
}
}
}
get breadcrumbLinks() {
if (!this.resource?.actor) return [];
const resourceActor = this.resource.actor;
const links = [
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(this.resource.actor) },
text: displayName(this.resource.actor),
},
{
name: RouteName.RESOURCE_FOLDER_ROOT,
params: { preferredUsername: usernameWithDomain(this.resource.actor) },
text: this.$t("Resources") as string,
},
];
links.push(
...this.filteredPath.map((pathFragment, index) => {
return {
name: RouteName.RESOURCE_FOLDER,
params: {
path: ResourceMixin.resourcePathArray(this.resource).slice(
0,
index + 1
) as unknown as string,
preferredUsername: usernameWithDomain(resourceActor),
},
text: pathFragment,
};
})
);
return links;
}
}
</script>
<style lang="scss" scoped>

View file

@ -1,19 +1,17 @@
<template>
<div v-if="loggedUser">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ACCOUNT_SETTINGS }">{{
$t("Account")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.ACCOUNT_SETTINGS_GENERAL }">{{
$t("General")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
text: $t('General'),
},
]"
/>
<section>
<div class="setting-title">
<h2>{{ $t("Email") }}</h2>

View file

@ -1,19 +1,17 @@
<template>
<div v-if="loggedUser">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ACCOUNT_SETTINGS }">{{
$t("Account")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.NOTIFICATIONS }">{{
$t("Notifications")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.NOTIFICATIONS,
text: $t('Notifications'),
},
]"
/>
<section>
<div class="setting-title">
<h2>{{ $t("Browser notifications") }}</h2>

View file

@ -1,19 +1,17 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.ACCOUNT_SETTINGS }">{{
$t("Account")
}}</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.PREFERENCES }">{{
$t("Preferences")
}}</router-link>
</li>
</ul>
</nav>
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.PREFERENCES,
text: $t('Preferences'),
},
]"
/>
<div>
<b-field :label="$t('Language')" label-for="setting-language">
<b-select

View file

@ -1,35 +1,29 @@
<template>
<section class="section container" v-if="todo">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
:to="{
<breadcrumbs-nav
:links="[
{
name: RouteName.GROUP,
params: {
preferredUsername: todo.todoList.actor.preferredUsername,
preferredUsername: usernameWithDomain(todo.todoList.actor),
},
}"
>{{ todo.todoList.actor.name }}</router-link
>
</li>
<li>
<router-link
:to="{
text: displayName(todo.todoList.actor),
},
{
name: RouteName.TODO_LISTS,
params: {
preferredUsername: usernameWithDomain(todo.todoList.actor),
},
text: $t('Task lists'),
},
{
name: RouteName.TODO_LIST,
params: { id: todo.todoList.id },
}"
>
{{ todo.todoList.title }}
</router-link>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.TODO }" aria-current="page">
{{ todo.title }}
</router-link>
</li>
</ul>
</nav>
text: todo.todoList.title,
},
{ name: RouteName.TODO, text: todo.title },
]"
/>
<full-todo :todo="todo" />
</section>
</template>
@ -39,6 +33,7 @@ import { GET_TODO } from "@/graphql/todos";
import { ITodo } from "@/types/todos";
import FullTodo from "@/components/Todo/FullTodo.vue";
import RouteName from "../../router/name";
import { displayName, usernameWithDomain } from "@/types/actor";
@Component({
components: {
@ -70,5 +65,9 @@ export default class Todo extends Vue {
todo!: ITodo;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
}
</script>

View file

@ -1,34 +1,24 @@
<template>
<section class="container section" v-if="todoList">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
:to="{
<breadcrumbs-nav
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: todoList.actor.preferredUsername },
}"
>{{ todoList.actor.name }}</router-link
>
</li>
<li>
<router-link
:to="{
params: { preferredUsername: usernameWithDomain(todoList.actor) },
text: displayName(group),
},
{
name: RouteName.TODO_LISTS,
params: { preferredUsername: todoList.actor.preferredUsername },
}"
>{{ $t("Task lists") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }"
>
{{ todoList.title }}
</router-link>
</li>
</ul>
</nav>
params: { preferredUsername: usernameWithDomain(todoList.actor) },
text: $t('Task lists'),
},
{
name: RouteName.TODO_LIST,
params: { id: todoList.id },
text: todoList.title,
},
]"
/>
<h2 class="title">{{ todoList.title }}</h2>
<div v-for="todo in todoList.todos.elements" :key="todo.id">
<compact-todo :todo="todo" />
@ -48,7 +38,7 @@ import { ITodo } from "@/types/todos";
import { CREATE_TODO, FETCH_TODO_LIST } from "@/graphql/todos";
import CompactTodo from "@/components/Todo/CompactTodo.vue";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { IActor } from "@/types/actor";
import { displayName, IActor, usernameWithDomain } from "@/types/actor";
import { ITodoList } from "@/types/todolist";
import RouteName from "../../router/name";
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
@ -89,6 +79,10 @@ export default class TodoList extends Vue {
RouteName = RouteName;
displayName = displayName;
usernameWithDomain = usernameWithDomain;
async createNewTodo(): Promise<void> {
await this.$apollo.mutate({
mutation: CREATE_TODO,

View file

@ -1,27 +1,19 @@
<template>
<div class="container section" v-if="group">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
:to="{
<breadcrumbs-nav
:links="[
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
text: displayName(group),
},
{
name: RouteName.TODO_LISTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Task lists") }}</router-link
>
</li>
</ul>
</nav>
text: $t('Task lists'),
},
]"
/>
<section>
<p>
{{
@ -61,7 +53,7 @@
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { FETCH_GROUP } from "@/graphql/group";
import { IGroup, usernameWithDomain } from "@/types/actor";
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
import { CREATE_TODO_LIST } from "@/graphql/todos";
import CompactTodo from "@/components/Todo/CompactTodo.vue";
import { ITodoList } from "@/types/todolist";
@ -108,6 +100,8 @@ export default class TodoLists extends Vue {
usernameWithDomain = usernameWithDomain;
displayName = displayName;
get todoLists(): ITodoList[] {
return this.group.todoLists.elements;
}

22
js/tailwind.config.js Normal file
View file

@ -0,0 +1,22 @@
function withOpacityValue(variable) {
return ({ opacityValue }) => {
if (opacityValue === undefined) {
return `rgb(var(${variable}))`;
}
return `rgb(var(${variable}) / ${opacityValue})`;
};
}
module.exports = {
content: ["./public/**/*.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
primary: withOpacityValue("--color-primary"),
secondary: withOpacityValue("--color-secondary"),
"violet-title": withOpacityValue("--color-violet-title"),
},
},
},
plugins: [require("@tailwindcss/line-clamp")],
};

File diff suppressed because it is too large Load diff

View file

@ -33,7 +33,10 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
case Actors.get_actor_by_url(url, preload) do
{:ok, %Actor{} = cached_actor} ->
if Actors.needs_update?(cached_actor) do
__MODULE__.make_actor_from_url(url, options)
case __MODULE__.make_actor_from_url(url, options) do
{:ok, %Actor{} = actor} -> {:ok, actor}
{:error, _} -> {:ok, cached_actor}
end
else
{:ok, cached_actor}
end
@ -83,11 +86,14 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
Logger.debug("Finding or making actor from nickname #{nickname}")
case Actors.get_actor_by_name_with_preload(nickname, type) do
%Actor{url: actor_url} = actor ->
if Actors.needs_update?(actor) do
make_actor_from_url(actor_url, preload: true)
%Actor{url: actor_url} = cached_actor ->
if Actors.needs_update?(cached_actor) do
case __MODULE__.make_actor_from_url(actor_url, preload: true) do
{:ok, %Actor{} = actor} -> {:ok, actor}
{:error, _} -> {:ok, cached_actor}
end
else
{:ok, actor}
{:ok, cached_actor}
end
nil ->
@ -102,15 +108,15 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
@doc """
Create an actor inside our database from username, using WebFinger to find out its AP ID and then fetch it
"""
@spec make_actor_from_nickname(nickname :: String.t(), preload :: boolean) ::
@spec make_actor_from_nickname(nickname :: String.t(), options :: Keyword.t()) ::
{:ok, Actor.t()} | {:error, make_actor_errors | WebFinger.finger_errors()}
def make_actor_from_nickname(nickname, preload \\ false) do
def make_actor_from_nickname(nickname, options \\ []) do
Logger.debug("Fingering actor from nickname #{nickname}")
case WebFinger.finger(nickname) do
{:ok, url} when is_binary(url) ->
Logger.debug("Matched #{nickname} to URL #{url}, now making actor")
make_actor_from_url(url, preload: preload)
make_actor_from_url(url, options)
{:error, e} ->
{:error, e}

View file

@ -20,7 +20,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
@doc """
Get audience for an entity
"""
@spec get_audience(Entity.t()) :: audience()
@spec get_audience(Entity.t() | Participant.t()) :: audience()
def get_audience(%Event{} = event) do
extract_actors_from_event(event)
end

View file

@ -190,7 +190,7 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
# If we're handling an activity
@spec handling_element(map()) :: {:ok, any, struct} | :error
@spec handling_element(String.t()) :: {:ok, struct} | {:error, any()}
@spec handling_element(String.t()) :: {:ok, struct} | {:ok, atom, struct} | {:error, any()}
defp handling_element(%{"type" => activity_type} = data)
when activity_type in ["Create", "Update", "Delete"] do
object = get_in(data, ["object"])

View file

@ -125,7 +125,11 @@ defmodule Mobilizon.Federation.WebFinger do
defp maybe_add_profile_page(data, _actor), do: data
@type finger_errors ::
:host_not_found | :address_invalid | :http_error | :webfinger_information_not_json
:host_not_found
| :address_invalid
| :http_error
| :webfinger_information_not_json
| :no_url_in_webfinger_data
@doc """
Finger an actor to retreive it's ActivityPub ID/URL
@ -144,6 +148,10 @@ defmodule Mobilizon.Federation.WebFinger do
{:ok, %{"url" => url}} ->
{:ok, url}
{:ok, _} ->
Logger.debug("No URL found for actor from webfinger data")
{:error, :no_url_in_webfinger_data}
{:error, err} ->
Logger.debug("Couldn't process webfinger data for #{actor}")
{:error, err}
@ -158,11 +166,14 @@ defmodule Mobilizon.Federation.WebFinger do
@spec fetch_webfinger_data(String.t()) ::
{:ok, map()} | {:error, :webfinger_information_not_json | :http_error}
defp fetch_webfinger_data(address) do
Logger.debug("Calling WebfingerClient with #{inspect(address)}")
case WebfingerClient.get(address) do
{:ok, %{body: body, status: code}} when code in 200..299 ->
webfinger_from_json(body)
_ ->
err ->
Logger.debug("Failed to fetch webfinger data #{inspect(err)}")
{:error, :http_error}
end
end
@ -173,12 +184,14 @@ defmodule Mobilizon.Federation.WebFinger do
case apply_webfinger_endpoint(actor) do
address when is_binary(address) ->
if address_invalid(address) do
Logger.info("Webfinger endpoint seems to be an invalid URL #{inspect(address)}")
{:error, :address_invalid}
else
{:ok, address}
end
_ ->
Logger.info("Host not found in actor address #{inspect(actor)}")
{:error, :host_not_found}
end
end
@ -188,12 +201,15 @@ defmodule Mobilizon.Federation.WebFinger do
@spec find_webfinger_endpoint(String.t()) ::
{:ok, String.t()} | {:error, :link_not_found} | {:error, any()}
defp find_webfinger_endpoint(domain) when is_binary(domain) do
Logger.debug("Calling HostMetaClient for #{domain}")
with {:ok, %Tesla.Env{status: 200, body: body}} <-
HostMetaClient.get("http://#{domain}/.well-known/host-meta"),
HostMetaClient.get("https://#{domain}/.well-known/host-meta"),
link_template when is_binary(link_template) <- find_link_from_template(body) do
{:ok, link_template}
else
{:ok, %Tesla.Env{status: 404}} -> {:error, :entity_not_found}
{:ok, %Tesla.Env{}} -> {:error, :http_error}
{:error, :link_not_found} -> {:error, :link_not_found}
{:error, error} -> {:error, error}
end
@ -204,10 +220,12 @@ defmodule Mobilizon.Federation.WebFinger do
with {:ok, domain} <- domain_from_federated_actor(actor) do
case find_webfinger_endpoint(domain) do
{:ok, link_template} ->
Logger.debug("Using webfinger location provided by host-meta endpoint")
String.replace(link_template, "{uri}", "acct:#{actor}")
_ ->
"http://#{domain}/.well-known/webfinger?resource=acct:#{actor}"
Logger.debug("Using default webfinger location")
"https://#{domain}/.well-known/webfinger?resource=acct:#{actor}"
end
end
end
@ -233,6 +251,10 @@ defmodule Mobilizon.Federation.WebFinger do
{"application/activity+json", "self"} ->
Map.put(data, "url", link["href"])
{nil, _rel} ->
Logger.debug("No type declared for the following link #{inspect(link)}")
data
_ ->
Logger.debug(fn ->
"Unhandled type to finger: #{inspect(link["type"])}"

View file

@ -41,7 +41,7 @@ defmodule Mobilizon.GraphQL.API.Follows do
"We're trying to accept a follow: #{followed_url} is accepting #{follower_url} follow request."
)
case Actors.is_following(follower, followed) do
case Actors.check_follow(follower, followed) do
%Follower{approved: false} = follow ->
Actions.Accept.accept(
:follow,
@ -68,8 +68,9 @@ defmodule Mobilizon.GraphQL.API.Follows do
"We're trying to reject a follow: #{followed_url} is rejecting #{follower_url} follow request."
)
case Actors.is_following(follower, followed) do
%Follower{approved: false} ->
case Actors.check_follow(follower, followed) do
%Follower{approved: false} = follow ->
Actors.delete_follower(follow)
{:error, "Follow already rejected"}
%Follower{} = follow ->

View file

@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
import Mobilizon.Users.Guards
alias Mobilizon.{Actors, Admin, Config, Events}
alias Mobilizon.{Actors, Admin, Config, Events, Instances, Users}
alias Mobilizon.Actors.{Actor, Follower}
alias Mobilizon.Admin.{ActionLog, Setting}
alias Mobilizon.Cldr.Language
@ -14,9 +14,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.{Actions, Relay}
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Service.Auth.Authenticator
alias Mobilizon.Service.Statistics
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
alias Mobilizon.Web.Email
import Mobilizon.Web.Gettext
require Logger
@ -281,6 +283,114 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
dgettext("errors", "You need to be logged-in and an administrator to save admin settings")}
end
@spec update_user(any, map(), Absinthe.Resolution.t()) ::
{:error, :invalid_argument | :user_not_found | binary | Ecto.Changeset.t()}
| {:ok, Mobilizon.Users.User.t()}
def update_user(_parent, %{id: id, notify: notify} = args, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
case Users.get_user(id) do
nil ->
{:error, :user_not_found}
%User{} = user ->
case args |> Map.drop([:notify, :id]) |> Map.keys() do
[] ->
{:error, :invalid_argument}
[change | _] ->
case change do
:email -> change_email(user, Map.get(args, :email), notify)
:role -> change_role(user, Map.get(args, :role), notify)
:confirmed -> confirm_user(user, Map.get(args, :confirmed), notify)
end
end
end
end
def update_user(_parent, _args, _resolution) do
{:error,
dgettext("errors", "You need to be logged-in and an administrator to edit an user's details")}
end
@spec change_email(User.t(), String.t(), boolean()) :: {:ok, User.t()} | {:error, String.t()}
defp change_email(%User{email: old_email} = user, new_email, notify) do
if Authenticator.can_change_email?(user) do
if new_email != old_email do
if Email.Checker.valid?(new_email) do
case Users.update_user(user, %{email: new_email}) do
{:ok, %User{} = updated_user} ->
if notify do
updated_user
|> Email.Admin.user_email_change_old(old_email)
|> Email.Mailer.send_email_later()
updated_user
|> Email.Admin.user_email_change_new(old_email)
|> Email.Mailer.send_email_later()
end
{:ok, updated_user}
{:error, %Ecto.Changeset{} = err} ->
Logger.debug(inspect(err))
{:error, dgettext("errors", "Failed to update user email")}
end
else
{:error, dgettext("errors", "The new email doesn't seem to be valid")}
end
else
{:error, dgettext("errors", "The new email must be different")}
end
end
end
@spec change_role(User.t(), Mobilizon.Users.UserRole.t(), boolean()) ::
{:ok, User.t()} | {:error, String.t() | Ecto.Changeset.t()}
defp change_role(%User{role: old_role} = user, new_role, notify) do
if old_role != new_role do
with {:ok, %User{} = user} <- Users.update_user(user, %{role: new_role}) do
if notify do
user
|> Email.Admin.user_role_change(old_role)
|> Email.Mailer.send_email_later()
end
{:ok, user}
end
else
{:error, dgettext("errors", "The new role must be different")}
end
end
@spec confirm_user(User.t(), boolean(), boolean()) ::
{:ok, User.t()} | {:error, String.t() | Ecto.Changeset.t()}
defp confirm_user(%User{confirmed_at: nil} = user, true, notify) do
with {:ok, %User{} = user} <-
Users.update_user(user, %{
confirmed_at: DateTime.utc_now(),
confirmation_sent_at: nil,
confirmation_token: nil
}) do
if notify do
user
|> Email.Admin.user_confirmation()
|> Email.Mailer.send_email_later()
end
{:ok, user}
end
end
defp confirm_user(%User{confirmed_at: %DateTime{}} = _user, true, _notify) do
{:error, dgettext("errors", "Can't confirm an already confirmed user")}
end
defp confirm_user(_user, _confirm, _notify) do
{:error, dgettext("errors", "Deconfirming users is not supported")}
end
@spec list_relay_followers(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Page.t(Follower.t())} | {:error, :unauthorized | :unauthenticated}
def list_relay_followers(
@ -329,16 +439,81 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
{:error, :unauthenticated}
end
def get_instances(
_parent,
args,
%{
context: %{current_user: %User{role: role}}
}
)
when is_admin(role) do
{:ok,
Instances.instances(
args
|> Keyword.new()
|> Keyword.take([
:page,
:limit,
:order_by,
:direction,
:filter_domain,
:filter_follow_status,
:filter_suspend_status
])
)}
end
def get_instances(_parent, _args, %{context: %{current_user: %User{}}}) do
{:error, :unauthorized}
end
def get_instances(_parent, _args, _resolution) do
{:error, :unauthenticated}
end
def get_instance(_parent, %{domain: domain}, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
has_relay = Actors.has_relay?(domain)
remote_relay = Actors.get_actor_by_name("relay@#{domain}")
local_relay = Relay.get_actor()
result = %{
has_relay: has_relay,
follower_status: follow_status(remote_relay, local_relay),
followed_status: follow_status(local_relay, remote_relay)
}
{:ok, Map.merge(Instances.instance(domain), result)}
end
def get_instance(_parent, _args, %{context: %{current_user: %User{}}}) do
{:error, :unauthorized}
end
def get_instance(_parent, _args, _resolution) do
{:error, :unauthenticated}
end
def create_instance(
parent,
%{domain: domain} = args,
%{context: %{current_user: %User{role: role}}} = resolution
)
when is_admin(role) do
with {:ok, _activity, _follow} <- Relay.follow(domain) do
Instances.refresh()
get_instance(parent, args, resolution)
end
end
@spec create_relay(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Follower.t()} | {:error, any()}
def create_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
when is_admin(role) do
case Relay.follow(address) do
{:ok, _activity, follow} ->
with {:ok, _activity, follow} <- Relay.follow(address) do
{:ok, follow}
{:error, err} ->
{:error, err}
end
end
@ -346,12 +521,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
{:ok, Follower.t()} | {:error, any()}
def remove_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
when is_admin(role) do
case Relay.unfollow(address) do
{:ok, _activity, follow} ->
with {:ok, _activity, follow} <- Relay.unfollow(address) do
{:ok, follow}
{:error, err} ->
{:error, err}
end
end
@ -363,12 +534,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
%{context: %{current_user: %User{role: role}}}
)
when is_admin(role) do
case Relay.accept(address) do
{:ok, _activity, follow} ->
with {:ok, _activity, follow} <- Relay.accept(address) do
{:ok, follow}
{:error, err} ->
{:error, err}
end
end
@ -380,12 +547,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
%{context: %{current_user: %User{role: role}}}
)
when is_admin(role) do
case Relay.reject(address) do
{:ok, _activity, follow} ->
with {:ok, _activity, follow} <- Relay.reject(address) do
{:ok, follow}
{:error, err} ->
{:error, err}
end
end
@ -425,4 +588,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
:ok
end
end
@spec follow_status(Actor.t() | nil, Actor.t() | nil) :: :approved | :pending | :none
defp follow_status(follower, followed) when follower != nil and followed != nil do
case Actors.check_follow(follower, followed) do
%Follower{approved: true} -> :approved
%Follower{approved: false} -> :pending
_ -> :none
end
end
defp follow_status(_, _), do: :none
end

View file

@ -17,11 +17,19 @@ defmodule Mobilizon.GraphQL.Resolvers.Report do
{:ok, Page.t(Report.t())} | {:error, String.t()}
def list_reports(
_parent,
%{page: page, limit: limit, status: status},
%{page: page, limit: limit} = args,
%{context: %{current_user: %User{role: role}}}
)
when is_moderator(role) do
{:ok, Mobilizon.Reports.list_reports(page, limit, :updated_at, :desc, status)}
{:ok,
Mobilizon.Reports.list_reports(
page: page,
limit: limit,
sort: :updated_at,
direction: :desc,
status: Map.get(args, :status),
domain: Map.get(args, :domain)
)}
end
def list_reports(_parent, _args, _resolution) do

View file

@ -48,11 +48,11 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
{:ok, Page.t(User.t())} | {:error, :unauthorized}
def list_users(
_parent,
%{email: email, page: page, limit: limit, sort: sort, direction: direction},
args,
%{context: %{current_user: %User{role: role}}}
)
when is_moderator(role) do
{:ok, Users.list_users(email, page, limit, sort, direction)}
{:ok, Users.list_users(Keyword.new(args))}
end
def list_users(_parent, _args, _resolution) do

View file

@ -153,6 +153,80 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
value(:custom, as: "CUSTOM", description: "Custom privacy policy text")
end
enum :instance_follow_status do
value(:approved, description: "The instance follow was approved")
value(:pending, description: "The instance follow is still pending")
value(:none, description: "There's no instance follow etablished")
end
enum :instances_sort_fields do
value(:event_count)
value(:person_count)
value(:group_count)
value(:followers_count)
value(:followings_count)
value(:reports_count)
value(:media_size)
end
enum :instance_filter_follow_status do
value(:all)
value(:following)
value(:followed)
end
enum :instance_filter_suspend_status do
value(:all)
value(:suspended)
end
@desc """
An instance representation
"""
object :instance do
field(:domain, :id, description: "The domain name of the instance")
field(:has_relay, :boolean, description: "Whether this instance has a Mobilizon relay actor")
field(:follower_status, :instance_follow_status, description: "Do we follow this instance")
field(:followed_status, :instance_follow_status, description: "Does this instance follow us?")
field(:event_count, :integer, description: "The number of events on this instance we know of")
field(:person_count, :integer,
description: "The number of profiles on this instance we know of"
)
field(:group_count, :integer, description: "The number of grouo on this instance we know of")
field(:followers_count, :integer,
description: "The number of their profiles who follow our groups"
)
field(:followings_count, :integer,
description: "The number of our profiles who follow their groups"
)
field(:reports_count, :integer,
description: "The number of reports made against profiles from this instance"
)
field(:media_size, :integer,
description: "The size of all the media files sent by actors from this instance"
)
field(:has_relay, :boolean,
description:
"Whether this instance has a relay, meaning that it's a Mobilizon instance that we can follow"
)
end
@desc """
A paginated list of instances
"""
object :paginated_instance_list do
field(:elements, list_of(:instance), description: "A list of instances")
field(:total, :integer, description: "The total number of instances in the list")
end
object :admin_queries do
@desc "Get the list of action logs"
field :action_logs, type: :paginated_action_log_list do
@ -226,9 +300,59 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
arg(:direction, :string, default_value: :desc, description: "The sorting direction")
resolve(&Admin.list_relay_followings/3)
end
@desc """
List instances
"""
field :instances, :paginated_instance_list do
arg(:page, :integer,
default_value: 1,
description: "The page in the paginated relay followings list"
)
arg(:limit, :integer,
default_value: 10,
description: "The limit of relay followings per page"
)
arg(:order_by, :instances_sort_fields,
default_value: :event_count,
description: "The field to order by the list"
)
arg(:filter_domain, :string, default_value: nil, description: "Filter by domain")
arg(:filter_follow_status, :instance_filter_follow_status,
default_value: :all,
description: "Whether or not to filter instances by the follow status"
)
arg(:filter_suspend_status, :instance_filter_suspend_status,
default_value: :all,
description: "Whether or not to filter instances by the suspended status"
)
arg(:direction, :string, default_value: :desc, description: "The sorting direction")
resolve(&Admin.get_instances/3)
end
@desc """
Get an instance's details
"""
field :instance, :instance do
arg(:domain, non_null(:id), description: "The instance domain")
resolve(&Admin.get_instance/3)
end
end
object :admin_mutations do
@desc "Add an instance subscription"
field :add_instance, type: :instance do
arg(:domain, non_null(:string), description: "The instance domain to add")
resolve(&Admin.create_instance/3)
end
@desc "Add a relay subscription"
field :add_relay, type: :follower do
arg(:address, non_null(:string), description: "The relay hostname to add")
@ -285,5 +409,22 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
resolve(&Admin.save_settings/3)
end
@desc """
For an admin to update an user
"""
field :admin_update_user, type: :user do
arg(:id, non_null(:id), description: "The user's ID")
arg(:email, :string, description: "The user's new email")
arg(:confirmed, :boolean, description: "Manually confirm the user's account")
arg(:role, :user_role, description: "Set user's new role")
arg(:notify, :boolean,
default_value: false,
description: "Whether or not to notify the user of the change"
)
resolve(&Admin.update_user/3)
end
end
end

View file

@ -67,6 +67,7 @@ defmodule Mobilizon.GraphQL.Schema.ReportType do
arg(:limit, :integer, default_value: 10, description: "The limit of reports per page")
arg(:status, :report_status, default_value: :open, description: "Filter reports by status")
arg(:domain, :string, default_value: nil, description: "Filter reports by domain name")
resolve(&Report.list_reports/3)
end

View file

@ -280,6 +280,11 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
@desc "List instance users"
field :users, :users do
arg(:email, :string, default_value: "", description: "Filter users by email")
arg(:current_sign_in_ip, :string,
description: "Filter users by current signed-in IP address"
)
arg(:page, :integer, default_value: 1, description: "The page in the paginated users list")
arg(:limit, :integer, default_value: 10, description: "The limit of users per page")

View file

@ -130,7 +130,7 @@ defmodule Mix.Tasks.Mobilizon.Instance do
options,
:listen_port,
"What port will the app listen to (leave it if you are using the default setup with nginx)?",
4000
"4000"
)
instance_secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64)

View file

@ -67,10 +67,6 @@ defmodule Mix.Tasks.Mobilizon.Users.New do
{:error, %Ecto.Changeset{errors: errors}} ->
shell_error(inspect(errors))
shell_error("User has not been created because of the above reason.")
err ->
shell_error(inspect(err))
shell_error("User has not been created because of an unknown reason.")
end
end

View file

@ -1176,7 +1176,7 @@ defmodule Mobilizon.Actors do
if followed.suspended do
{:error, :followed_suspended}
else
case is_following(follower, followed) do
case check_follow(follower, followed) do
%Follower{} ->
{:error, :already_following}
@ -1202,7 +1202,7 @@ defmodule Mobilizon.Actors do
@spec unfollow(Actor.t(), Actor.t()) ::
{:ok, Follower.t()} | {:error, Ecto.Changeset.t() | String.t()}
def unfollow(%Actor{} = followed, %Actor{} = follower) do
case {:already_following, is_following(follower, followed)} do
case {:already_following, check_follow(follower, followed)} do
{:already_following, %Follower{} = follow} ->
delete_follower(follow)
@ -1214,8 +1214,8 @@ defmodule Mobilizon.Actors do
@doc """
Checks whether an actor is following another actor.
"""
@spec is_following(Actor.t(), Actor.t()) :: Follower.t() | nil
def is_following(%Actor{} = follower_actor, %Actor{} = followed_actor) do
@spec check_follow(Actor.t(), Actor.t()) :: Follower.t() | nil
def check_follow(%Actor{} = follower_actor, %Actor{} = followed_actor) do
get_follower_by_followed_and_following(followed_actor, follower_actor)
end
@ -1256,6 +1256,16 @@ defmodule Mobilizon.Actors do
:ok
end
@spec has_relay?(String.t()) :: boolean()
def has_relay?(domain) do
Actor
|> where(
[a],
a.preferred_username == "relay" and a.domain == ^domain and a.type == :Application
)
|> Repo.exists?()
end
@spec delete_files_if_media_changed(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp delete_files_if_media_changed(%Ecto.Changeset{changes: changes, data: data} = changeset) do
Enum.each([:avatar, :banner], fn key ->
@ -1465,7 +1475,7 @@ defmodule Mobilizon.Actors do
|> where([_q, ..., a], like(a.name, ^"%#{name}%") or like(a.preferred_username, ^"%#{name}%"))
end
@spec join_members_actor(Ecto.Query.t()) :: Ecto.Query.t()
@spec join_members_actor(Ecto.Queryable.t()) :: Ecto.Query.t()
defp join_members_actor(query) do
join(query, :inner, [q], a in Actor, on: q.actor_id == a.id)
end

View file

@ -0,0 +1,19 @@
defmodule Mobilizon.Instances.Instance do
@moduledoc """
An instance representation
Using a MATERIALIZED VIEW underneath
"""
use Ecto.Schema
@primary_key {:domain, :string, []}
schema "instances" do
field(:event_count, :integer)
field(:person_count, :integer)
field(:group_count, :integer)
field(:followers_count, :integer)
field(:followings_count, :integer)
field(:reports_count, :integer)
field(:media_size, :integer)
end
end

View file

@ -0,0 +1,115 @@
defmodule Mobilizon.Instances do
@moduledoc """
The instances context
"""
alias Ecto.Adapters.SQL
alias Mobilizon.Actors.{Actor, Follower}
alias Mobilizon.Instances.Instance
alias Mobilizon.Storage.{Page, Repo}
import Ecto.Query
@is_null_fragment "CASE WHEN ? IS NULL THEN FALSE ELSE TRUE END"
@spec instances(Keyword.t()) :: Page.t(Instance.t())
def instances(options) do
page = Keyword.get(options, :page)
limit = Keyword.get(options, :limit)
order_by = Keyword.get(options, :order_by)
direction = Keyword.get(options, :direction)
filter_domain = Keyword.get(options, :filter_domain)
# suspend_status = Keyword.get(options, :filter_suspend_status)
follow_status = Keyword.get(options, :filter_follow_status)
order_by_options = Keyword.new([{direction, order_by}])
subquery =
Actor
|> where(
[a],
a.preferred_username == "relay" and a.type == :Application and not is_nil(a.domain)
)
|> join(:left, [a], f1 in Follower, on: f1.target_actor_id == a.id)
|> join(:left, [a], f2 in Follower, on: f2.actor_id == a.id)
|> select([a, f1, f2], %{
domain: a.domain,
has_relay: fragment(@is_null_fragment, a.id),
following: fragment(@is_null_fragment, f2.id),
following_approved: f2.approved,
follower: fragment(@is_null_fragment, f1.id),
follower_approved: f1.approved
})
query =
Instance
|> join(:left, [i], s in subquery(subquery), on: i.domain == s.domain)
|> select([i, s], {i, s})
|> order_by(^order_by_options)
query =
if is_nil(filter_domain) or filter_domain == "" do
query
else
where(query, [i], like(i.domain, ^"%#{filter_domain}%"))
end
query =
case follow_status do
:following -> where(query, [i, s], s.following == true)
:followed -> where(query, [i, s], s.follower == true)
:all -> query
end
%Page{elements: elements} = paged_instances = Page.build_page(query, page, limit, :domain)
%Page{
paged_instances
| elements: Enum.map(elements, &convert_instance_meta/1)
}
end
@spec instance(String.t()) :: Instance.t()
def instance(domain) do
Instance
|> where(domain: ^domain)
|> Repo.one()
end
@spec all_domains :: list(Instance.t())
def all_domains do
Instance
|> distinct(true)
|> select([:domain])
|> Repo.all()
end
@spec refresh :: %{
:rows => nil | [[term()] | binary()],
:num_rows => non_neg_integer(),
optional(atom()) => any()
}
def refresh do
SQL.query!(Repo, "REFRESH MATERIALIZED VIEW instances")
end
defp convert_instance_meta(
{instance,
%{
domain: _domain,
follower: follower,
follower_approved: follower_approved,
following: following,
following_approved: following_approved,
has_relay: has_relay
}}
) do
instance
|> Map.put(:follower_status, follow_status(following, following_approved))
|> Map.put(:followed_status, follow_status(follower, follower_approved))
|> Map.put(:has_relay, has_relay)
end
defp follow_status(true, true), do: :approved
defp follow_status(true, false), do: :pending
defp follow_status(false, _), do: :none
defp follow_status(nil, _), do: :none
end

View file

@ -8,6 +8,7 @@ defmodule Mobilizon.Reports do
import Mobilizon.Storage.Ecto
alias Mobilizon.Actors.Actor
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Storage.{Page, Repo}
@ -49,17 +50,18 @@ defmodule Mobilizon.Reports do
@doc """
Returns the list of reports.
"""
@spec list_reports(integer | nil, integer | nil, atom, atom, ReportStatus.t()) ::
Page.t(Report.t())
def list_reports(
page \\ nil,
limit \\ nil,
sort \\ :updated_at,
direction \\ :asc,
status \\ :open
) do
@spec list_reports(Keyword.t()) :: Page.t(Report.t())
def list_reports(options) do
page = Keyword.get(options, :page)
limit = Keyword.get(options, :limit)
sort = Keyword.get(options, :sort, :updated_at)
direction = Keyword.get(options, :direction, :asc)
status = Keyword.get(options, :status, :open)
domain = Keyword.get(options, :domain)
status
|> list_reports_query()
|> filter_domain_name(domain)
|> sort(sort, direction)
|> Page.build_page(page, limit)
end
@ -99,11 +101,19 @@ defmodule Mobilizon.Reports do
@spec list_reports_query(ReportStatus.t()) :: Ecto.Query.t()
defp list_reports_query(status) do
from(
r in Report,
preload: [:reported, :reporter, :manager, :event, :comments, :notes],
where: r.status == ^status
)
Report
|> preload([:reported, :reporter, :manager, :event, :comments, :notes])
|> where([r], r.status == ^status)
end
@spec filter_domain_name(Ecto.Queryable.t(), String.t() | nil) :: Ecto.Queryable.t()
defp filter_domain_name(query, nil), do: query
defp filter_domain_name(query, ""), do: query
defp filter_domain_name(query, domain) do
query
|> join(:inner, [r], a in Actor, on: a.id == r.reported_id)
|> where([_r, a], like(a.domain, ^"%#{domain}%"))
end
@spec count_reports_query :: Ecto.Query.t()

View file

@ -272,23 +272,14 @@ defmodule Mobilizon.Users do
@doc """
Returns the list of users.
"""
@spec list_users(String.t(), integer | nil, integer | nil, atom, atom) ::
Page.t(User.t())
def list_users(email, page, limit \\ nil, sort, direction)
def list_users("", page, limit, sort, direction) do
@spec list_users(Keyword.t()) :: Page.t(User.t())
def list_users(options) do
User
|> sort(sort, direction)
|> filter_by_email(Keyword.get(options, :email))
|> filter_by_ip(Keyword.get(options, :current_sign_in_ip))
|> sort(Keyword.get(options, :sort), Keyword.get(options, :direction))
|> preload([u], [:actors, :feed_tokens, :settings, :default_actor])
|> Page.build_page(page, limit)
end
def list_users(email, page, limit, sort, direction) do
User
|> where([u], ilike(u.email, ^"%#{email}%"))
|> sort(sort, direction)
|> preload([u], [:actors, :feed_tokens, :settings, :default_actor])
|> Page.build_page(page, limit)
|> Page.build_page(Keyword.get(options, :page), Keyword.get(options, :limit))
end
@doc """
@ -527,4 +518,16 @@ defmodule Mobilizon.Users do
defp update_user_default_actor_query(user_id) do
where(User, [u], u.id == ^user_id)
end
@spec filter_by_email(Ecto.Queryable.t(), String.t() | nil) :: Ecto.Query.t()
defp filter_by_email(query, nil), do: query
defp filter_by_email(query, ""), do: query
defp filter_by_email(query, email), do: where(query, [q], ilike(q.email, ^"%#{email}%"))
@spec filter_by_ip(Ecto.Queryable.t(), String.t() | nil) :: Ecto.Query.t()
defp filter_by_ip(query, nil), do: query
defp filter_by_ip(query, ""), do: query
defp filter_by_ip(query, current_sign_in_ip),
do: where(query, [q], q.current_sign_in_ip == ^current_sign_in_ip)
end

View file

@ -163,6 +163,7 @@ defmodule Mobilizon.Service.Export.ICalendar do
end
defp shift_tz(%DateTime{} = date, _), do: date
defp shift_tz(nil, _), do: nil
defp organizer(%Event{attributed_to: %Actor{} = group}) do
Actor.display_name(group)

View file

@ -8,7 +8,12 @@ defmodule Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker do
@impl Oban.Worker
def perform(%Job{}) do
if Mobilizon.Config.get!([:instance, :remove_unconfirmed_users]) and should_perform?() do
remove_unconfirmed_users =
:mobilizon
|> Application.get_env(:instance)
|> Keyword.get(:remove_unconfirmed_users, false)
if remove_unconfirmed_users and should_perform?() do
CleanUnconfirmedUsers.clean()
end
end

View file

@ -41,7 +41,7 @@ defmodule Mobilizon.Service.Workers.Helper do
alias Oban.Job
@spec enqueue(String.t(), map(), Keyword.t()) ::
@spec enqueue(String.t() | :atom, map(), Keyword.t()) ::
{:ok, Job.t()} | {:error, Ecto.Changeset.t()}
def enqueue(operation, params, worker_args \\ []) do
params = Map.merge(%{"op" => operation}, params)

View file

@ -0,0 +1,31 @@
defmodule Mobilizon.Service.Workers.RefreshInstances do
@moduledoc """
Worker to refresh the instances materialized view and the relay actors
"""
use Oban.Worker, unique: [period: :infinity, keys: [:event_uuid, :action]]
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
alias Mobilizon.Instances
alias Mobilizon.Instances.Instance
alias Oban.Job
@impl Oban.Worker
@spec perform(Oban.Job.t()) :: :ok
def perform(%Job{}) do
Instances.refresh()
Instances.all_domains()
|> Enum.each(&refresh_instance_actor/1)
end
@spec refresh_instance_actor(Instance.t()) ::
{:ok, Mobilizon.Actors.Actor.t()}
| {:error,
Mobilizon.Federation.ActivityPub.Actor.make_actor_errors()
| Mobilizon.Federation.WebFinger.finger_errors()}
defp refresh_instance_actor(%Instance{domain: domain}) do
ActivityPubActor.find_or_make_actor_from_nickname("relay@#{domain}")
end
end

View file

@ -32,4 +32,100 @@ defmodule Mobilizon.Web.Email.Admin do
|> assign(:report, report)
|> render(:report)
end
@spec user_email_change_old(User.t(), String.t()) :: Bamboo.Email.t()
def user_email_change_old(
%User{
locale: user_locale,
email: new_email
},
old_email
) do
Gettext.put_locale(user_locale)
subject =
gettext(
"An administrator manually changed the email attached to your account on %{instance}",
instance: Config.instance_name()
)
Email.base_email(to: old_email, subject: subject)
|> assign(:locale, user_locale)
|> assign(:subject, subject)
|> assign(:new_email, new_email)
|> assign(:old_email, old_email)
|> assign(:offer_unsupscription, false)
|> render(:admin_user_email_changed_old)
end
@spec user_email_change_new(User.t(), String.t()) :: Bamboo.Email.t()
def user_email_change_new(
%User{
locale: user_locale,
email: new_email
},
old_email
) do
Gettext.put_locale(user_locale)
subject =
gettext(
"An administrator manually changed the email attached to your account on %{instance}",
instance: Config.instance_name()
)
Email.base_email(to: new_email, subject: subject)
|> assign(:locale, user_locale)
|> assign(:subject, subject)
|> assign(:old_email, old_email)
|> assign(:new_email, new_email)
|> assign(:offer_unsupscription, false)
|> render(:admin_user_email_changed_new)
end
@spec user_role_change(User.t(), atom()) :: Bamboo.Email.t()
def user_role_change(
%User{
locale: user_locale,
email: email,
role: new_role
},
old_role
) do
Gettext.put_locale(user_locale)
subject =
gettext(
"An administrator updated your role on %{instance}",
instance: Config.instance_name()
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, user_locale)
|> assign(:subject, subject)
|> assign(:old_role, old_role)
|> assign(:new_role, new_role)
|> assign(:offer_unsupscription, false)
|> render(:admin_user_role_changed)
end
@spec user_confirmation(User.t()) :: Bamboo.Email.t()
def user_confirmation(%User{
locale: user_locale,
email: email
}) do
Gettext.put_locale(user_locale)
subject =
gettext(
"An administrator confirmed your account on %{instance}",
instance: Config.instance_name()
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, user_locale)
|> assign(:subject, subject)
|> assign(:offer_unsupscription, false)
|> render(:admin_user_confirmation)
end
end

View file

@ -0,0 +1,8 @@
<%= case @role do %>
<% :administrator -> %>
<b><%= gettext "Administrator" %></b>
<% :moderator -> %>
<b><%= gettext "Moderator" %></b>
<% :user -> %>
<b><%= gettext "User" %></b>
<% end %>

View file

@ -0,0 +1 @@
<%= case @role do %><% :administrator -> %><%= gettext "Administrator" %><% :moderator -> %><%= gettext "Moderator" %><% :user -> %><%= gettext "User" %><% end %>

View file

@ -0,0 +1,82 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "An administrator manually confirmed your account" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext("Hi there! We just wanted to inform you that an administrator from <b>%{instance}</b> just manually confirmed your account.", %{instance: @instance_name}) |> raw %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext("You may now login using your credentials on the service.") %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#3C376E">
<a href={"#{ "#{Mobilizon.Web.Endpoint.url()}/login" }"} target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;">
<%= gettext "Login on %{instance}", %{instance: @instance_name} %>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
<p style="margin: 0">
<%= gettext("If something doesn't feel right to you, please contact the instance administrator through the contact methods %{start_link}on the instance's about page%{end_link}.", %{start_link: "<a href=\"#{Mobilizon.Web.Endpoint.url()}/about/instance\">", end_link: "</a>"}) |> raw %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View file

@ -0,0 +1,7 @@
<%= gettext "An administrator manually confirmed your account" %>
==
<%= gettext "Hi there! We just wanted to inform you that an administrator from %{instance} just manually confirmed your account.", %{instance: @instance_name} %>
<%= gettext "You may now login using your credentials on the service:" %> <%= "#{Mobilizon.Web.Endpoint.url()}/login" %>
<%= gettext "If something doesn't feel right to you, please contact the instance administrator through the contact methods on the instance's about page: %{about_page}.", %{about_page: "#{Mobilizon.Web.Endpoint.url()}/about/instance"} %>

View file

@ -0,0 +1,56 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "An administrator manually changed the email attached to your account" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext("Hi there! We just wanted to inform you that an administrator from <b>%{instance}</b> just manually changed your account email from <b>%{old_email}</b> to <b>%{new_email}</b> (this one).", %{instance: @instance_name, old_email: @old_email, new_email: @new_email}) |> raw %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
<p style="margin: 0">
<%= gettext("If something doesn't feel right to you, please contact the instance administrator through the contact methods %{start_link}on the instance's about page%{end_link}.", %{start_link: "<a href=\"#{Mobilizon.Web.Endpoint.url()}/about/instance\">", end_link: "</a>"}) |> raw %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View file

@ -0,0 +1,4 @@
<%= gettext "An administrator manually changed the email attached to your account" %>
==
<%= gettext "Hi there! We just wanted to inform you that an administrator from %{instance} just manually changed your account email from %{old_email} (this one) to %{new_email}.", %{instance: @instance_name, old_email: @old_email, new_email: @new_email} %>
<%= gettext "If something doesn't feel right to you, please contact the instance administrator through the contact methods on the instance's about page: %{about_page}.", %{about_page: "#{Mobilizon.Web.Endpoint.url()}/about/instance"} %>

View file

@ -0,0 +1,56 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "An administrator manually changed the email attached to your account" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext("Hi there! We just wanted to inform you that an administrator from <b>%{instance}</b> just manually changed your account email from <b>%{old_email}</b> (this one) to <b>%{new_email}</b>.", %{instance: @instance_name, old_email: @old_email, new_email: @new_email}) |> raw %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
<p style="margin: 0">
<%= gettext("If something doesn't feel right to you, please contact the instance administrator through the contact methods %{start_link}on the instance's about page%{end_link}.", %{start_link: "<a href=\"#{Mobilizon.Web.Endpoint.url()}/about/instance\">", end_link: "</a>"}) |> raw %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View file

@ -0,0 +1,4 @@
<%= gettext "An administrator manually changed the email attached to your account" %>
==
<%= gettext "Hi there! We just wanted to inform you that an administrator from %{instance} just manually changed your account email from %{old_email} (this one) to %{new_email}.", %{instance: @instance_name, old_email: @old_email, new_email: @new_email} %>
<%= gettext "If something doesn't feel right to you, please contact the instance administrator through the contact methods on the instance's about page: %{about_page}.", %{about_page: "#{Mobilizon.Web.Endpoint.url()}/about/instance"} %>

View file

@ -0,0 +1,78 @@
<!-- HERO -->
<tr>
<td bgcolor="#474467" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #3A384C; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "An administrator changed your role" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#E6E4F4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext("Hi there! We just wanted to inform you that an administrator from <b>%{instance}</b> just changed your account role.", %{instance: @instance_name}) |> raw %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<table width="100%">
<tr>
<td bgcolor="#ffffff" align="left">
<%= gettext "Old role" %>
</td>
<td bgcolor="#ffffff" align="left">
<%= render("admin/_role.html", role: @old_role) %>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left">
<%= gettext "New role" %>
</td>
<td bgcolor="#ffffff" align="left">
<%= render("admin/_role.html", role: @new_role) %>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
<p style="margin: 0">
<%= gettext("If something doesn't feel right to you, please contact the instance administrator through the contact methods %{start_link}on the instance's about page%{end_link}.", %{start_link: "<a href=\"#{Mobilizon.Web.Endpoint.url()}/about/instance\">", end_link: "</a>"}) |> raw %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View file

@ -0,0 +1,8 @@
<%= gettext "An administrator changed your role" %>
==
<%= gettext "Hi there! We just wanted to inform you that an administrator from %{instance} just changed your account role.", %{instance: @instance_name} %>
<%= gettext "Old role:" %> <%= render("admin/_role.text", role: @old_role) %>
<%= gettext "New role:" %> <%= render("admin/_role.text", role: @new_role) %>
<%= gettext "If something doesn't feel right to you, please contact the instance administrator through the contact methods on the instance's about page: %{about_page}.", %{about_page: "#{Mobilizon.Web.Endpoint.url()}/about/instance"} %>

View file

@ -1,5 +1,5 @@
<%= gettext "Confirm new email" %>
==
<%= gettext "Hi there! It seems like you wanted to change the email address linked to your account on <b>%{instance}</b>. If you still wish to do so, please click the button below to confirm the change. You will then be able to log in to %{instance} with this new email address.", %{instance: @instance_name} %>
<%= gettext "Hi there! It seems like you wanted to change the email address linked to your account on %{instance}. If you still wish to do so, please click the button below to confirm the change. You will then be able to log in to %{instance} with this new email address.", %{instance: @instance_name} %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :user_email_validation, @token) %>
<%= gettext "If you didn't trigger the change yourself, please ignore this message." %>

Some files were not shown because too many files have changed in this diff Show more