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: [ crontab: [
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background}, {"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background},
{"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, 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.CleanOrphanMediaWorker, queue: :background},
{"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background}, {"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background},
{"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background}, {"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background},

View File

@ -16,6 +16,7 @@
"@absinthe/socket-apollo-link": "^0.2.1", "@absinthe/socket-apollo-link": "^0.2.1",
"@apollo/client": "^3.3.16", "@apollo/client": "^3.3.16",
"@mdi/font": "^6.1.95", "@mdi/font": "^6.1.95",
"@tailwindcss/line-clamp": "^0.3.0",
"@tiptap/core": "^2.0.0-beta.41", "@tiptap/core": "^2.0.0-beta.41",
"@tiptap/extension-blockquote": "^2.0.0-beta.25", "@tiptap/extension-blockquote": "^2.0.0-beta.25",
"@tiptap/extension-bold": "^2.0.0-beta.24", "@tiptap/extension-bold": "^2.0.0-beta.24",
@ -41,6 +42,7 @@
"@vue-a11y/skip-to": "^2.1.2", "@vue-a11y/skip-to": "^2.1.2",
"@vue/apollo-option": "4.0.0-alpha.11", "@vue/apollo-option": "4.0.0-alpha.11",
"apollo-absinthe-upload-link": "^1.5.0", "apollo-absinthe-upload-link": "^1.5.0",
"autoprefixer": "^10",
"blurhash": "^1.1.3", "blurhash": "^1.1.3",
"buefy": "^0.9.0", "buefy": "^0.9.0",
"bulma-divider": "^0.2.0", "bulma-divider": "^0.2.0",
@ -57,8 +59,10 @@
"ngeohash": "^0.6.3", "ngeohash": "^0.6.3",
"p-debounce": "^4.0.0", "p-debounce": "^4.0.0",
"phoenix": "^1.6", "phoenix": "^1.6",
"postcss": "^8",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"sanitize-html": "^2.5.3", "sanitize-html": "^2.5.3",
"tailwindcss": "^3",
"tippy.js": "^6.2.3", "tippy.js": "^6.2.3",
"unfetch": "^4.2.0", "unfetch": "^4.2.0",
"v-tooltip": "^2.1.3", "v-tooltip": "^2.1.3",
@ -112,6 +116,7 @@
"sass-loader": "^12.0.0", "sass-loader": "^12.0.0",
"ts-jest": "27", "ts-jest": "27",
"typescript": "~4.4.3", "typescript": "~4.4.3",
"vue-cli-plugin-tailwind": "^3.0.0-beta.0",
"vue-i18n-extract": "^2.0.4", "vue-i18n-extract": "^2.0.4",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",
"webpack-cli": "^4.7.0" "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 // Set the focus to the router view
// https://marcus.io/blog/accessible-routing-vuejs // https://marcus.io/blog/accessible-routing-vuejs
setTimeout(() => { 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) { if (focusTarget) {
// Make focustarget programmatically focussable // Make focustarget programmatically focussable
focusTarget.setAttribute("tabindex", "-1"); focusTarget.setAttribute("tabindex", "-1");

View File

@ -70,6 +70,9 @@ export const typePolicies: TypePolicies = {
participantStats: { merge: replaceMergePolicy }, participantStats: { merge: replaceMergePolicy },
}, },
}, },
Instance: {
keyFields: ["domain"],
},
RootQueryType: { RootQueryType: {
fields: { fields: {
relayFollowers: paginatedLimitPagination<IFollower>(), 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-announcer.scss";
@import "styles/vue-skip-to.scss"; @import "styles/vue-skip-to.scss";
// a {
// color: $violet-2;
// }
a.out, a.out,
.content a, .content a,
.ProseMirror a { .ProseMirror a {
@ -19,18 +15,10 @@ a.out,
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
} }
// input.input {
// border-color: $input-border-color !important;
// }
.section { .section {
padding: 1rem 1% 4rem; padding: 1rem 1% 4rem;
} }
figure img.is-rounded {
border: 1px solid #cdcaea;
}
$color-black: #000; $color-black: #000;
.mention { .mention {

View File

@ -1,31 +1,75 @@
<template> <template>
<div class="media" style="align-items: top" dir="auto"> <div
<div class="media-left"> 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="image is-32x32" v-if="actor.avatar"> >
<img class="is-rounded" :src="actor.avatar.url" alt="" /> <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"
/>
<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': 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> </figure>
<b-icon v-else size="is-medium" icon="account-circle" /> <b-icon
v-else
size="is-large"
icon="account-circle"
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
</div> </div>
<div class="media-content"> <div class="flex-1 min-w-0">
<p> <h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
{{ actor.name || `@${usernameWithDomain(actor)}` }} {{ displayName(actor) }}
</p> </h5>
<p class="has-text-grey-dark" v-if="actor.name"> <p class="text-gray-500 truncate" v-if="actor.name">
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span> <span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
</p> </p>
<div <div
v-if="full" v-if="full"
class="summary" class="line-clamp-3"
:class="{ limit: limit }" :class="{ limit: limit }"
v-html="actor.summary" v-html="actor.summary"
/> />
</div> </div>
</div> </div> -->
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator"; import { Component, Vue, Prop } from "vue-property-decorator";
import { IActor, usernameWithDomain } from "../../types/actor"; import { displayName, IActor, usernameWithDomain } from "../../types/actor";
@Component @Component
export default class ActorCard extends Vue { 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; @Prop({ required: false, type: Boolean, default: true }) limit!: boolean;
usernameWithDomain = usernameWithDomain; usernameWithDomain = usernameWithDomain;
displayName = displayName;
} }
</script> </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" class="metadata-organized-by"
:title="$t('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 <router-link
v-if="event.attributedTo" v-if="event.attributedTo"
:to="{ :to="{
@ -49,23 +43,19 @@
}, },
}" }"
> >
<popover-actor-card <actor-card
:actor="event.attributedTo"
v-if=" v-if="
!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent !event.attributedTo || !event.options.hideOrganizerWhenGroupEvent
" "
> :actor="event.attributedTo"
<actor-card :actor="event.attributedTo" /> />
</popover-actor-card> <actor-card v-else :actor="event.organizerActor" />
</router-link> </router-link>
<actor-card
<popover-actor-card
:actor="contact" :actor="contact"
v-for="contact in event.contacts" v-for="contact in event.contacts"
:key="contact.id" :key="contact.id"
> />
<actor-card :actor="contact" />
</popover-actor-card>
</event-metadata-block> </event-metadata-block>
<event-metadata-block <event-metadata-block
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)" v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"

View File

@ -78,7 +78,7 @@
/> />
<SettingMenuItem <SettingMenuItem
:title="$t('Federation')" :title="$t('Federation')"
:to="{ name: RouteName.RELAYS }" :to="{ name: RouteName.INSTANCES }"
/> />
</SettingMenuSection> </SettingMenuSection>
</ul> </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} ${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` export const ADD_RELAY = gql`
mutation addRelay($address: String!) { mutation addRelay($address: String!) {
addRelay(address: $address) { addRelay(address: $address) {
@ -190,3 +251,26 @@ export const SAVE_ADMIN_SETTINGS = gql`
} }
${ADMIN_SETTINGS_FRAGMENT} ${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"; import { ACTOR_FRAGMENT } from "./actor";
export const REPORTS = gql` export const REPORTS = gql`
query Reports($status: ReportStatus, $page: Int, $limit: Int) { query Reports(
reports(status: $status, page: $page, limit: $limit) { $status: ReportStatus
$domain: String
$page: Int
$limit: Int
) {
reports(status: $status, domain: $domain, page: $page, limit: $limit) {
total total
elements { elements {
id id

View File

@ -209,14 +209,30 @@ export const UPDATE_ACTIVITY_SETTING = gql`
`; `;
export const LIST_USERS = gql` export const LIST_USERS = gql`
query ListUsers($email: String, $page: Int, $limit: Int) { query ListUsers(
users(email: $email, page: $page, limit: $limit) { $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 total
elements { elements {
id id
email email
locale locale
confirmedAt confirmedAt
currentSignInIp
currentSignInAt
disabled disabled
actors { actors {
...ActorFragment ...ActorFragment

View File

@ -1260,5 +1260,52 @@
"This profile was not found": "This profile was not found", "This profile was not found": "This profile was not found",
"Back to profile list": "Back to profile list", "Back to profile list": "Back to profile list",
"This user was not found": "This user was not found", "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.", "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 title": "Titre de l'élement",
"Element value": "Valeur de l'élement", "Element value": "Valeur de l'élement",
"Email": "Email", "Email": "Courriel",
"Email address": "Adresse email", "Email address": "Adresse email",
"Email validate": "Validation de l'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.", "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})", "{timezoneLongName} ({timezoneShortName})": "{timezoneLongName} ({timezoneShortName})",
"{title} ({count} todos)": "{title} ({count} todos)", "{title} ({count} todos)": "{title} ({count} todos)",
"{username} was invited to {group}": "{username} a été invité à {group}", "{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 filters from "./filters";
import { i18n } from "./utils/i18n"; import { i18n } from "./utils/i18n";
import apolloProvider from "./vue-apollo"; import apolloProvider from "./vue-apollo";
import Breadcrumbs from "@/components/Utils/Breadcrumbs.vue";
import "./registerServiceWorker"; import "./registerServiceWorker";
import "./assets/tailwind.css";
Vue.config.productionTip = false; Vue.config.productionTip = false;
@ -24,6 +26,7 @@ Vue.use(VueScrollTo);
Vue.use(VTooltip); Vue.use(VTooltip);
Vue.use(VueAnnouncer); Vue.use(VueAnnouncer);
Vue.use(VueSkipTo); Vue.use(VueSkipTo);
Vue.component("breadcrumbs-nav", Breadcrumbs);
// Register the router hooks with their names // Register the router hooks with their names
Component.registerHooks([ 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 = "ADMIN",
ADMIN_DASHBOARD = "ADMIN_DASHBOARD", ADMIN_DASHBOARD = "ADMIN_DASHBOARD",
ADMIN_SETTINGS = "ADMIN_SETTINGS", ADMIN_SETTINGS = "ADMIN_SETTINGS",
RELAYS = "Relays", INSTANCES = "INSTANCES",
RELAY_FOLLOWINGS = "Followings", INSTANCE = "INSTANCE",
RELAY_FOLLOWERS = "Followers",
USERS = "USERS", USERS = "USERS",
PROFILES = "PROFILES", PROFILES = "PROFILES",
ADMIN_PROFILE = "ADMIN_PROFILE", ADMIN_PROFILE = "ADMIN_PROFILE",
@ -21,7 +20,7 @@ export enum SettingsRouteName {
ADMIN_GROUPS = "ADMIN_GROUPS", ADMIN_GROUPS = "ADMIN_GROUPS",
ADMIN_GROUP_PROFILE = "ADMIN_GROUP_PROFILE", ADMIN_GROUP_PROFILE = "ADMIN_GROUP_PROFILE",
MODERATION = "MODERATION", MODERATION = "MODERATION",
REPORTS = "Reports", REPORTS = "REPORTS",
REPORT = "Report", REPORT = "Report",
REPORT_LOGS = "Logs", REPORT_LOGS = "Logs",
CREATE_IDENTITY = "CreateIdentity", CREATE_IDENTITY = "CreateIdentity",
@ -199,44 +198,35 @@ export const settingsRoutes: RouteConfig[] = [
meta: { requiredAuth: true, announcer: { skip: true } }, meta: { requiredAuth: true, announcer: { skip: true } },
}, },
{ {
path: "admin/relays", path: "admin/instances",
name: SettingsRouteName.RELAYS, name: SettingsRouteName.INSTANCES,
redirect: { name: SettingsRouteName.RELAY_FOLLOWINGS },
component: (): Promise<ImportedComponent> => component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "Follows" */ "@/views/Admin/Follows.vue"), import(
meta: { requiredAuth: true, announcer: { skip: true } }, /* webpackChunkName: "Instances" */ "@/views/Admin/Instances.vue"
children: [ ),
{ meta: {
path: "followings", requiredAuth: true,
name: SettingsRouteName.RELAY_FOLLOWINGS, announcer: {
component: (): Promise<ImportedComponent> => message: (): string => i18n.t("Instances") as string,
import(
/* webpackChunkName: "Followings" */ "@/components/Admin/Followings.vue"
),
meta: {
requiredAuth: true,
announcer: {
message: (): string => i18n.t("Followings") 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, 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", path: "/moderation",
name: SettingsRouteName.MODERATION, name: SettingsRouteName.MODERATION,
@ -244,7 +234,7 @@ export const settingsRoutes: RouteConfig[] = [
meta: { requiredAuth: true, announcer: { skip: true } }, meta: { requiredAuth: true, announcer: { skip: true } },
}, },
{ {
path: "/moderation/reports/:filter?", path: "/moderation/reports",
name: SettingsRouteName.REPORTS, name: SettingsRouteName.REPORTS,
component: (): Promise<ImportedComponent> => component: (): Promise<ImportedComponent> =>
import( import(

View File

@ -276,3 +276,15 @@ export enum EventMetadataCategories {
BOOKING = "BOOKING", BOOKING = "BOOKING",
VIDEO_CONFERENCE = "VIDEO_CONFERENCE", 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 // https://stackoverflow.com/a/18650828/10204399
function formatBytes(bytes: number, decimals = 2): string { function formatBytes(bytes: number, decimals = 2, zero = "0 Bytes"): string {
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return zero;
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;

View File

@ -139,3 +139,17 @@ $subtitle-sup-size: 15px;
$breadcrumb-item-color: $primary; $breadcrumb-item-color: $primary;
$checkbox-background-color: #fff; $checkbox-background-color: #fff;
$title-color: $violet-3; $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> <template>
<div> <div>
<nav class="breadcrumb" aria-label="breadcrumbs"> <breadcrumbs-nav :links="breadcrumbsLinks" />
<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>
<div class="root" v-if="identity"> <div class="root" v-if="identity">
<h1 class="title"> <h1 class="title">
<span v-if="isUpdate">{{ identity.displayName() }}</span> <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 { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
import pick from "lodash/pick"; import pick from "lodash/pick";
import { ActorType } from "@/types/enums"; import { ActorType } from "@/types/enums";
import { Location } from "vue-router";
@Component({ @Component({
components: { components: {
@ -670,5 +649,29 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
this.oldDisplayName = null; this.oldDisplayName = null;
this.avatarFile = 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> </script>

View File

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

View File

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

View File

@ -1,73 +1,335 @@
<template> <template>
<div v-if="user" class="section"> <div v-if="user" class="section">
<nav class="breadcrumb" aria-label="breadcrumbs"> <breadcrumbs-nav
<ul> :links="[
<li> { name: RouteName.ADMIN, text: $t('Admin') },
<router-link :to="{ name: RouteName.ADMIN }">{{ {
$t("Admin") name: RouteName.USERS,
}}</router-link> text: $t('Users'),
</li> },
<li> {
<router-link name: RouteName.ADMIN_USER_PROFILE,
:to="{ params: { id: user.id },
name: RouteName.USERS, text: user.email,
}" },
>{{ $t("Users") }}</router-link ]"
> />
</li>
<li class="is-active"> <section>
<router-link <h2 class="text-lg font-bold mb-3">{{ $t("Details") }}</h2>
:to="{ <div class="flex flex-col">
name: RouteName.ADMIN_USER_PROFILE, <div class="overflow-x-auto sm:-mx-6">
params: { id: user.id }, <div class="inline-block py-2 min-w-full sm:px-2">
}" <div class="overflow-hidden shadow-md sm:rounded-lg">
>{{ user.email }}</router-link <table v-if="metadata.length > 0" class="min-w-full">
> <tbody>
</li> <tr
</ul> class="odd:bg-white even:bg-gray-50 border-b"
</nav> v-for="{ key, value, link, type } in metadata"
<table v-if="metadata.length > 0" class="table is-fullwidth"> :key="key"
<tbody> >
<tr v-for="{ key, value, link, elements, type } in metadata" :key="key"> <td class="py-4 px-2 whitespace-nowrap align-middle">
<td>{{ key }}</td> {{ key }}
<td v-if="elements && elements.length > 0"> </td>
<ul <td v-if="link" class="py-4 px-2 whitespace-nowrap">
v-for="{ value, link: elementLink, active } in elements" <router-link :to="link">
:key="value" {{ value }}
> </router-link>
<li> </td>
<router-link :to="elementLink"> <td
<span v-if="active">{{ v-else-if="type === 'ip'"
$t("{profile} (by default)", { profile: value }) class="py-4 px-2 whitespace-nowrap"
}}</span> >
<span v-else>{{ value }}</span> <code>{{ value }}</code>
</router-link> </td>
</li> <td
</ul> v-else-if="type === 'role'"
</td> class="py-4 px-2 whitespace-nowrap"
<td v-else-if="elements"> >
{{ $t("None") }} <span
</td> :class="{
<td v-else-if="link"> 'bg-red-100 text-red-800':
<router-link :to="link"> user.role == ICurrentUserRole.ADMINISTRATOR,
{{ value }} 'bg-yellow-100 text-yellow-800':
</router-link> user.role == ICurrentUserRole.MODERATOR,
</td> 'bg-blue-100 text-blue-800':
<td v-else-if="type == 'code'"> user.role == ICurrentUserRole.USER,
<code>{{ value }}</code> }"
</td> class="text-sm font-medium mr-2 px-2.5 py-0.5 rounded"
<td v-else>{{ value }}</td> >
</tr> {{ value }}
</tbody> </span>
</table> </td>
<div class="buttons"> <td v-else class="py-4 px-2 align-middle">
<b-button {{ value }}
@click="deleteAccount" </td>
v-if="!user.disabled" <td
type="is-primary" v-if="type === 'email'"
>{{ $t("Suspend") }}</b-button 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>
</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"
> >
</div> <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> </div>
<empty-content v-else-if="!$apollo.loading" icon="account"> <empty-content v-else-if="!$apollo.loading" icon="account">
{{ $t("This user was not found") }} {{ $t("This user was not found") }}
@ -82,16 +344,16 @@
</empty-content> </empty-content>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator"; import { Component, Vue, Prop, Watch } from "vue-property-decorator";
import { Route } from "vue-router";
import { formatBytes } from "@/utils/datetime"; import { formatBytes } from "@/utils/datetime";
import { ICurrentUserRole } from "@/types/enums"; import { ICurrentUserRole } from "@/types/enums";
import { GET_USER, SUSPEND_USER } from "../../graphql/user"; 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 RouteName from "../../router/name";
import { IUser } from "../../types/current-user.model"; import { IUser } from "../../types/current-user.model";
import { IPerson } from "../../types/actor";
import EmptyContent from "../../components/Utils/EmptyContent.vue"; import EmptyContent from "../../components/Utils/EmptyContent.vue";
import ActorCard from "../../components/Account/ActorCard.vue";
import { ADMIN_UPDATE_USER, LANGUAGES_CODES } from "@/graphql/admin";
@Component({ @Component({
apollo: { apollo: {
@ -107,6 +369,17 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
return !this.id; return !this.id;
}, },
}, },
languages: {
query: LANGUAGES_CODES,
variables() {
return {
codes: [this.languageCode],
};
},
skip() {
return !this.languageCode;
},
},
}, },
metaInfo() { metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -118,6 +391,7 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
}, },
components: { components: {
EmptyContent, EmptyContent,
ActorCard,
}, },
}) })
export default class AdminUserProfile extends Vue { export default class AdminUserProfile extends Vue {
@ -125,24 +399,45 @@ export default class AdminUserProfile extends Vue {
user!: IUser; user!: IUser;
languages!: Array<{ code: string; name: string }>;
usernameWithDomain = usernameWithDomain; usernameWithDomain = usernameWithDomain;
RouteName = RouteName; 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>> { get metadata(): Array<Record<string, unknown>> {
if (!this.user) return []; if (!this.user) return [];
return [ return [
{ {
key: this.$i18n.t("Email"), key: this.$i18n.t("Email"),
value: this.user.email, value: this.user.email,
type: "email",
}, },
{ {
key: this.$i18n.t("Language"), 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"), key: this.$i18n.t("Role"),
value: this.roleName(this.user.role), value: this.roleName(this.user.role),
type: "role",
}, },
{ {
key: this.$i18n.t("Login status"), key: this.$i18n.t("Login status"),
@ -150,26 +445,13 @@ export default class AdminUserProfile extends Vue {
? this.$i18n.t("Disabled") ? this.$i18n.t("Disabled")
: this.$t("Activated"), : 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"), key: this.$i18n.t("Confirmed"),
value: value:
this.$options.filters && this.user.confirmedAt this.$options.filters && this.user.confirmedAt
? this.$options.filters.formatDateTimeString(this.user.confirmedAt) ? this.$options.filters.formatDateTimeString(this.user.confirmedAt)
: this.$i18n.t("Not confirmed"), : this.$i18n.t("Not confirmed"),
type: "confirmed",
}, },
{ {
key: this.$i18n.t("Last sign-in"), key: this.$i18n.t("Last sign-in"),
@ -183,15 +465,19 @@ export default class AdminUserProfile extends Vue {
{ {
key: this.$i18n.t("Last IP adress"), key: this.$i18n.t("Last IP adress"),
value: this.user.currentSignInIp || this.$t("Unknown"), 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, value: this.user.participations.total,
}, },
{ {
key: this.$i18n.t("Uploaded media size"), key: this.$i18n.t("Uploaded media total size"),
value: formatBytes(this.user.mediaSize), value: formatBytes(
this.user.mediaSize,
2,
this.$i18n.t("0 Bytes") as string
),
}, },
]; ];
} }
@ -208,20 +494,89 @@ export default class AdminUserProfile extends Vue {
} }
} }
async deleteAccount(): Promise<Route> { async suspendAccount(): Promise<void> {
await this.$apollo.mutate<{ suspendProfile: { id: string } }>({ this.$buefy.dialog.confirm({
mutation: SUSPEND_USER, title: this.$t("Suspend the account?") as string,
variables: { message: this.$t(
userId: this.id, "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: {
userId: this.id,
},
});
return this.$router.push({ name: RouteName.USERS });
}, },
}); });
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> </script>
<style lang="scss" scoped>
table {
margin: 2rem 0;
}
</style>

View File

@ -1,19 +1,11 @@
<template> <template>
<div> <div>
<nav class="breadcrumb" aria-label="breadcrumbs"> <breadcrumbs-nav
<ul> :links="[
<li> { name: RouteName.ADMIN, text: $t('Admin') },
<router-link :to="{ name: RouteName.ADMIN }">{{ { text: $t('Dashboard') },
$t("Admin") ]"
}}</router-link> />
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.ADMIN_DASHBOARD }">{{
$t("Dashboard")
}}</router-link>
</li>
</ul>
</nav>
<section> <section>
<h1 class="title">{{ $t("Administration") }}</h1> <h1 class="title">{{ $t("Administration") }}</h1>
<div class="tile is-ancestor" v-if="dashboard"> <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> <template>
<div> <div>
<nav class="breadcrumb" aria-label="breadcrumbs"> <breadcrumbs-nav
<ul> :links="[
<li> { name: RouteName.MODERATION, text: $t('Moderation') },
<router-link :to="{ name: RouteName.MODERATION }">{{ {
$t("Moderation") name: RouteName.ADMIN_GROUPS,
}}</router-link> text: $t('Groups'),
</li> },
<li class="is-active"> ]"
<router-link :to="{ name: RouteName.PROFILES }">{{ />
$t("Groups")
}}</router-link>
</li>
</ul>
</nav>
<div class="buttons" v-if="showCreateGroupsButton"> <div class="buttons" v-if="showCreateGroupsButton">
<router-link <router-link
class="button is-primary" 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> <template>
<div> <div>
<nav class="breadcrumb" aria-label="breadcrumbs"> <breadcrumbs-nav
<ul> :links="[
<li> { name: RouteName.MODERATION, text: $t('Moderation') },
<router-link :to="{ name: RouteName.MODERATION }">{{ {
$t("Moderation") name: RouteName.PROFILES,
}}</router-link> text: $t('Profiles'),
</li> },
<li class="is-active"> ]"
<router-link :to="{ name: RouteName.PROFILES }">{{ />
$t("Profiles")
}}</router-link>
</li>
</ul>
</nav>
<div v-if="persons"> <div v-if="persons">
<b-switch v-model="local">{{ $t("Local") }}</b-switch> <b-switch v-model="local">{{ $t("Local") }}</b-switch>
<b-switch v-model="suspended">{{ $t("Suspended") }}</b-switch> <b-switch v-model="suspended">{{ $t("Suspended") }}</b-switch>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,43 +2,7 @@
<div> <div>
<form @submit.prevent="publish(false)" v-if="isCurrentActorAGroupModerator"> <form @submit.prevent="publish(false)" v-if="isCurrentActorAGroupModerator">
<div class="container section"> <div class="container section">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="actualGroup"> <breadcrumbs-nav v-if="actualGroup" :links="breadcrumbLinks" />
<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>
<h1 class="title" v-if="isUpdate === true"> <h1 class="title" v-if="isUpdate === true">
{{ $t("Edit post") }} {{ $t("Edit post") }}
</h1> </h1>
@ -174,7 +138,7 @@ import { CREATE_POST, UPDATE_POST } from "../../graphql/post";
import { IPost } from "../../types/post.model"; import { IPost } from "../../types/post.model";
import Editor from "../../components/Editor.vue"; 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 TagInput from "../../components/Event/TagInput.vue";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import Subtitle from "../../components/Utils/Subtitle.vue"; import Subtitle from "../../components/Utils/Subtitle.vue";
@ -366,6 +330,39 @@ export default class EditPost extends mixins(GroupMixin, PostMixin) {
} }
return this.group; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

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

View File

@ -1,80 +1,38 @@
<template> <template>
<div class="container section" v-if="resource"> <div class="container section" v-if="resource">
<nav class="breadcrumb" aria-label="breadcrumbs"> <breadcrumbs-nav :links="breadcrumbLinks">
<ul> <li>
<li> <b-dropdown aria-role="list">
<router-link <b-button class="button is-primary" slot="trigger">+</b-button>
: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>
<li>
<b-dropdown aria-role="list">
<b-button class="button is-primary" slot="trigger">+</b-button>
<b-dropdown-item aria-role="listitem" @click="createFolderModal"> <b-dropdown-item aria-role="listitem" @click="createFolderModal">
<b-icon icon="folder" /> <b-icon icon="folder" />
{{ $t("New folder") }} {{ $t("New folder") }}
</b-dropdown-item> </b-dropdown-item>
<b-dropdown-item <b-dropdown-item
aria-role="listitem" aria-role="listitem"
@click="createLinkResourceModal = true" @click="createLinkResourceModal = true"
> >
<b-icon icon="link" /> <b-icon icon="link" />
{{ $t("New link") }} {{ $t("New link") }}
</b-dropdown-item> </b-dropdown-item>
<hr <hr
role="presentation" role="presentation"
class="dropdown-divider" class="dropdown-divider"
v-if="resourceProviders.length" v-if="resourceProviders.length"
/> />
<b-dropdown-item <b-dropdown-item
aria-role="listitem" aria-role="listitem"
v-for="resourceProvider in resourceProviders" v-for="resourceProvider in resourceProviders"
:key="resourceProvider.software" :key="resourceProvider.software"
@click="createResourceFromProvider(resourceProvider)" @click="createResourceFromProvider(resourceProvider)"
> >
<b-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" /> <b-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" />
{{ createSentenceForType(resourceProvider.software) }} {{ createSentenceForType(resourceProvider.software) }}
</b-dropdown-item> </b-dropdown-item>
</b-dropdown> </b-dropdown>
</li> </li>
</ul> </breadcrumbs-nav>
</nav>
<section> <section>
<p v-if="resource.path === '/'" class="module-description"> <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 FolderItem from "@/components/Resource/FolderItem.vue";
import Draggable from "vuedraggable"; import Draggable from "vuedraggable";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; 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 RouteName from "../../router/name";
import { import {
IResource, 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,19 @@
<template> <template>
<div class="container section" v-if="group"> <div class="container section" v-if="group">
<nav class="breadcrumb" aria-label="breadcrumbs"> <breadcrumbs-nav
<ul> :links="[
<li> {
<router-link name: RouteName.GROUP,
:to="{ params: { preferredUsername: usernameWithDomain(group) },
name: RouteName.GROUP, text: displayName(group),
params: { preferredUsername: usernameWithDomain(group) }, },
}" {
>{{ group.name }}</router-link name: RouteName.TODO_LISTS,
> params: { preferredUsername: usernameWithDomain(group) },
</li> text: $t('Task lists'),
<li class="is-active"> },
<router-link ]"
:to="{ />
name: RouteName.TODO_LISTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Task lists") }}</router-link
>
</li>
</ul>
</nav>
<section> <section>
<p> <p>
{{ {{
@ -61,7 +53,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { FETCH_GROUP } from "@/graphql/group"; 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 { CREATE_TODO_LIST } from "@/graphql/todos";
import CompactTodo from "@/components/Todo/CompactTodo.vue"; import CompactTodo from "@/components/Todo/CompactTodo.vue";
import { ITodoList } from "@/types/todolist"; import { ITodoList } from "@/types/todolist";
@ -108,6 +100,8 @@ export default class TodoLists extends Vue {
usernameWithDomain = usernameWithDomain; usernameWithDomain = usernameWithDomain;
displayName = displayName;
get todoLists(): ITodoList[] { get todoLists(): ITodoList[] {
return this.group.todoLists.elements; 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 case Actors.get_actor_by_url(url, preload) do
{:ok, %Actor{} = cached_actor} -> {:ok, %Actor{} = cached_actor} ->
if Actors.needs_update?(cached_actor) do 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 else
{:ok, cached_actor} {:ok, cached_actor}
end end
@ -83,11 +86,14 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
Logger.debug("Finding or making actor from nickname #{nickname}") Logger.debug("Finding or making actor from nickname #{nickname}")
case Actors.get_actor_by_name_with_preload(nickname, type) do case Actors.get_actor_by_name_with_preload(nickname, type) do
%Actor{url: actor_url} = actor -> %Actor{url: actor_url} = cached_actor ->
if Actors.needs_update?(actor) do if Actors.needs_update?(cached_actor) do
make_actor_from_url(actor_url, preload: true) case __MODULE__.make_actor_from_url(actor_url, preload: true) do
{:ok, %Actor{} = actor} -> {:ok, actor}
{:error, _} -> {:ok, cached_actor}
end
else else
{:ok, actor} {:ok, cached_actor}
end end
nil -> nil ->
@ -102,15 +108,15 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
@doc """ @doc """
Create an actor inside our database from username, using WebFinger to find out its AP ID and then fetch it 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()} {: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}") Logger.debug("Fingering actor from nickname #{nickname}")
case WebFinger.finger(nickname) do case WebFinger.finger(nickname) do
{:ok, url} when is_binary(url) -> {:ok, url} when is_binary(url) ->
Logger.debug("Matched #{nickname} to URL #{url}, now making actor") 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} ->
{:error, e} {:error, e}

View File

@ -20,7 +20,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
@doc """ @doc """
Get audience for an entity 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 def get_audience(%Event{} = event) do
extract_actors_from_event(event) extract_actors_from_event(event)
end end

View File

@ -190,7 +190,7 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
# If we're handling an activity # If we're handling an activity
@spec handling_element(map()) :: {:ok, any, struct} | :error @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) defp handling_element(%{"type" => activity_type} = data)
when activity_type in ["Create", "Update", "Delete"] do when activity_type in ["Create", "Update", "Delete"] do
object = get_in(data, ["object"]) 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 defp maybe_add_profile_page(data, _actor), do: data
@type finger_errors :: @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 """ @doc """
Finger an actor to retreive it's ActivityPub ID/URL Finger an actor to retreive it's ActivityPub ID/URL
@ -144,6 +148,10 @@ defmodule Mobilizon.Federation.WebFinger do
{:ok, %{"url" => url}} -> {:ok, %{"url" => url}} ->
{:ok, url} {:ok, url}
{:ok, _} ->
Logger.debug("No URL found for actor from webfinger data")
{:error, :no_url_in_webfinger_data}
{:error, err} -> {:error, err} ->
Logger.debug("Couldn't process webfinger data for #{actor}") Logger.debug("Couldn't process webfinger data for #{actor}")
{:error, err} {:error, err}
@ -158,11 +166,14 @@ defmodule Mobilizon.Federation.WebFinger do
@spec fetch_webfinger_data(String.t()) :: @spec fetch_webfinger_data(String.t()) ::
{:ok, map()} | {:error, :webfinger_information_not_json | :http_error} {:ok, map()} | {:error, :webfinger_information_not_json | :http_error}
defp fetch_webfinger_data(address) do defp fetch_webfinger_data(address) do
Logger.debug("Calling WebfingerClient with #{inspect(address)}")
case WebfingerClient.get(address) do case WebfingerClient.get(address) do
{:ok, %{body: body, status: code}} when code in 200..299 -> {:ok, %{body: body, status: code}} when code in 200..299 ->
webfinger_from_json(body) webfinger_from_json(body)
_ -> err ->
Logger.debug("Failed to fetch webfinger data #{inspect(err)}")
{:error, :http_error} {:error, :http_error}
end end
end end
@ -173,12 +184,14 @@ defmodule Mobilizon.Federation.WebFinger do
case apply_webfinger_endpoint(actor) do case apply_webfinger_endpoint(actor) do
address when is_binary(address) -> address when is_binary(address) ->
if address_invalid(address) do if address_invalid(address) do
Logger.info("Webfinger endpoint seems to be an invalid URL #{inspect(address)}")
{:error, :address_invalid} {:error, :address_invalid}
else else
{:ok, address} {:ok, address}
end end
_ -> _ ->
Logger.info("Host not found in actor address #{inspect(actor)}")
{:error, :host_not_found} {:error, :host_not_found}
end end
end end
@ -188,12 +201,15 @@ defmodule Mobilizon.Federation.WebFinger do
@spec find_webfinger_endpoint(String.t()) :: @spec find_webfinger_endpoint(String.t()) ::
{:ok, String.t()} | {:error, :link_not_found} | {:error, any()} {:ok, String.t()} | {:error, :link_not_found} | {:error, any()}
defp find_webfinger_endpoint(domain) when is_binary(domain) do defp find_webfinger_endpoint(domain) when is_binary(domain) do
Logger.debug("Calling HostMetaClient for #{domain}")
with {:ok, %Tesla.Env{status: 200, body: body}} <- 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 link_template when is_binary(link_template) <- find_link_from_template(body) do
{:ok, link_template} {:ok, link_template}
else else
{:ok, %Tesla.Env{status: 404}} -> {:error, :entity_not_found} {:ok, %Tesla.Env{status: 404}} -> {:error, :entity_not_found}
{:ok, %Tesla.Env{}} -> {:error, :http_error}
{:error, :link_not_found} -> {:error, :link_not_found} {:error, :link_not_found} -> {:error, :link_not_found}
{:error, error} -> {:error, error} {:error, error} -> {:error, error}
end end
@ -204,10 +220,12 @@ defmodule Mobilizon.Federation.WebFinger do
with {:ok, domain} <- domain_from_federated_actor(actor) do with {:ok, domain} <- domain_from_federated_actor(actor) do
case find_webfinger_endpoint(domain) do case find_webfinger_endpoint(domain) do
{:ok, link_template} -> {:ok, link_template} ->
Logger.debug("Using webfinger location provided by host-meta endpoint")
String.replace(link_template, "{uri}", "acct:#{actor}") 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 end
end end
@ -233,6 +251,10 @@ defmodule Mobilizon.Federation.WebFinger do
{"application/activity+json", "self"} -> {"application/activity+json", "self"} ->
Map.put(data, "url", link["href"]) Map.put(data, "url", link["href"])
{nil, _rel} ->
Logger.debug("No type declared for the following link #{inspect(link)}")
data
_ -> _ ->
Logger.debug(fn -> Logger.debug(fn ->
"Unhandled type to finger: #{inspect(link["type"])}" "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." "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 -> %Follower{approved: false} = follow ->
Actions.Accept.accept( Actions.Accept.accept(
:follow, :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." "We're trying to reject a follow: #{followed_url} is rejecting #{follower_url} follow request."
) )
case Actors.is_following(follower, followed) do case Actors.check_follow(follower, followed) do
%Follower{approved: false} -> %Follower{approved: false} = follow ->
Actors.delete_follower(follow)
{:error, "Follow already rejected"} {:error, "Follow already rejected"}
%Follower{} = follow -> %Follower{} = follow ->

View File

@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
import Mobilizon.Users.Guards 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.Actors.{Actor, Follower}
alias Mobilizon.Admin.{ActionLog, Setting} alias Mobilizon.Admin.{ActionLog, Setting}
alias Mobilizon.Cldr.Language alias Mobilizon.Cldr.Language
@ -14,9 +14,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.{Actions, Relay} alias Mobilizon.Federation.ActivityPub.{Actions, Relay}
alias Mobilizon.Reports.{Note, Report} alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Service.Auth.Authenticator
alias Mobilizon.Service.Statistics alias Mobilizon.Service.Statistics
alias Mobilizon.Storage.Page alias Mobilizon.Storage.Page
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Web.Email
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
require Logger 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")} dgettext("errors", "You need to be logged-in and an administrator to save admin settings")}
end 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()) :: @spec list_relay_followers(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Page.t(Follower.t())} | {:error, :unauthorized | :unauthenticated} {:ok, Page.t(Follower.t())} | {:error, :unauthorized | :unauthenticated}
def list_relay_followers( def list_relay_followers(
@ -329,16 +439,81 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
{:error, :unauthenticated} {:error, :unauthenticated}
end 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()) :: @spec create_relay(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Follower.t()} | {:error, any()} {:ok, Follower.t()} | {:error, any()}
def create_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}}) def create_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
when is_admin(role) do when is_admin(role) do
case Relay.follow(address) do with {:ok, _activity, follow} <- Relay.follow(address) do
{:ok, _activity, follow} -> {:ok, follow}
{:ok, follow}
{:error, err} ->
{:error, err}
end end
end end
@ -346,12 +521,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
{:ok, Follower.t()} | {:error, any()} {:ok, Follower.t()} | {:error, any()}
def remove_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}}) def remove_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
when is_admin(role) do when is_admin(role) do
case Relay.unfollow(address) do with {:ok, _activity, follow} <- Relay.unfollow(address) do
{:ok, _activity, follow} -> {:ok, follow}
{:ok, follow}
{:error, err} ->
{:error, err}
end end
end end
@ -363,12 +534,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
%{context: %{current_user: %User{role: role}}} %{context: %{current_user: %User{role: role}}}
) )
when is_admin(role) do when is_admin(role) do
case Relay.accept(address) do with {:ok, _activity, follow} <- Relay.accept(address) do
{:ok, _activity, follow} -> {:ok, follow}
{:ok, follow}
{:error, err} ->
{:error, err}
end end
end end
@ -380,12 +547,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
%{context: %{current_user: %User{role: role}}} %{context: %{current_user: %User{role: role}}}
) )
when is_admin(role) do when is_admin(role) do
case Relay.reject(address) do with {:ok, _activity, follow} <- Relay.reject(address) do
{:ok, _activity, follow} -> {:ok, follow}
{:ok, follow}
{:error, err} ->
{:error, err}
end end
end end
@ -425,4 +588,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
:ok :ok
end end
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 end

View File

@ -17,11 +17,19 @@ defmodule Mobilizon.GraphQL.Resolvers.Report do
{:ok, Page.t(Report.t())} | {:error, String.t()} {:ok, Page.t(Report.t())} | {:error, String.t()}
def list_reports( def list_reports(
_parent, _parent,
%{page: page, limit: limit, status: status}, %{page: page, limit: limit} = args,
%{context: %{current_user: %User{role: role}}} %{context: %{current_user: %User{role: role}}}
) )
when is_moderator(role) do 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 end
def list_reports(_parent, _args, _resolution) do 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} {:ok, Page.t(User.t())} | {:error, :unauthorized}
def list_users( def list_users(
_parent, _parent,
%{email: email, page: page, limit: limit, sort: sort, direction: direction}, args,
%{context: %{current_user: %User{role: role}}} %{context: %{current_user: %User{role: role}}}
) )
when is_moderator(role) do when is_moderator(role) do
{:ok, Users.list_users(email, page, limit, sort, direction)} {:ok, Users.list_users(Keyword.new(args))}
end end
def list_users(_parent, _args, _resolution) do 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") value(:custom, as: "CUSTOM", description: "Custom privacy policy text")
end 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 object :admin_queries do
@desc "Get the list of action logs" @desc "Get the list of action logs"
field :action_logs, type: :paginated_action_log_list do 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") arg(:direction, :string, default_value: :desc, description: "The sorting direction")
resolve(&Admin.list_relay_followings/3) resolve(&Admin.list_relay_followings/3)
end 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 end
object :admin_mutations do 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" @desc "Add a relay subscription"
field :add_relay, type: :follower do field :add_relay, type: :follower do
arg(:address, non_null(:string), description: "The relay hostname to add") 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) resolve(&Admin.save_settings/3)
end 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
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(: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(: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) resolve(&Report.list_reports/3)
end end

View File

@ -280,6 +280,11 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
@desc "List instance users" @desc "List instance users"
field :users, :users do field :users, :users do
arg(:email, :string, default_value: "", description: "Filter users by email") 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(: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") 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, options,
:listen_port, :listen_port,
"What port will the app listen to (leave it if you are using the default setup with nginx)?", "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) 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}} -> {:error, %Ecto.Changeset{errors: errors}} ->
shell_error(inspect(errors)) shell_error(inspect(errors))
shell_error("User has not been created because of the above reason.") 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
end end

View File

@ -1176,7 +1176,7 @@ defmodule Mobilizon.Actors do
if followed.suspended do if followed.suspended do
{:error, :followed_suspended} {:error, :followed_suspended}
else else
case is_following(follower, followed) do case check_follow(follower, followed) do
%Follower{} -> %Follower{} ->
{:error, :already_following} {:error, :already_following}
@ -1202,7 +1202,7 @@ defmodule Mobilizon.Actors do
@spec unfollow(Actor.t(), Actor.t()) :: @spec unfollow(Actor.t(), Actor.t()) ::
{:ok, Follower.t()} | {:error, Ecto.Changeset.t() | String.t()} {:ok, Follower.t()} | {:error, Ecto.Changeset.t() | String.t()}
def unfollow(%Actor{} = followed, %Actor{} = follower) do 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} -> {:already_following, %Follower{} = follow} ->
delete_follower(follow) delete_follower(follow)
@ -1214,8 +1214,8 @@ defmodule Mobilizon.Actors do
@doc """ @doc """
Checks whether an actor is following another actor. Checks whether an actor is following another actor.
""" """
@spec is_following(Actor.t(), Actor.t()) :: Follower.t() | nil @spec check_follow(Actor.t(), Actor.t()) :: Follower.t() | nil
def is_following(%Actor{} = follower_actor, %Actor{} = followed_actor) do def check_follow(%Actor{} = follower_actor, %Actor{} = followed_actor) do
get_follower_by_followed_and_following(followed_actor, follower_actor) get_follower_by_followed_and_following(followed_actor, follower_actor)
end end
@ -1256,6 +1256,16 @@ defmodule Mobilizon.Actors do
:ok :ok
end 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() @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 defp delete_files_if_media_changed(%Ecto.Changeset{changes: changes, data: data} = changeset) do
Enum.each([:avatar, :banner], fn key -> 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}%")) |> where([_q, ..., a], like(a.name, ^"%#{name}%") or like(a.preferred_username, ^"%#{name}%"))
end 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 defp join_members_actor(query) do
join(query, :inner, [q], a in Actor, on: q.actor_id == a.id) join(query, :inner, [q], a in Actor, on: q.actor_id == a.id)
end 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 import Mobilizon.Storage.Ecto
alias Mobilizon.Actors.Actor
alias Mobilizon.Reports.{Note, Report} alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
@ -49,17 +50,18 @@ defmodule Mobilizon.Reports do
@doc """ @doc """
Returns the list of reports. Returns the list of reports.
""" """
@spec list_reports(integer | nil, integer | nil, atom, atom, ReportStatus.t()) :: @spec list_reports(Keyword.t()) :: Page.t(Report.t())
Page.t(Report.t()) def list_reports(options) do
def list_reports( page = Keyword.get(options, :page)
page \\ nil, limit = Keyword.get(options, :limit)
limit \\ nil, sort = Keyword.get(options, :sort, :updated_at)
sort \\ :updated_at, direction = Keyword.get(options, :direction, :asc)
direction \\ :asc, status = Keyword.get(options, :status, :open)
status \\ :open domain = Keyword.get(options, :domain)
) do
status status
|> list_reports_query() |> list_reports_query()
|> filter_domain_name(domain)
|> sort(sort, direction) |> sort(sort, direction)
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@ -99,11 +101,19 @@ defmodule Mobilizon.Reports do
@spec list_reports_query(ReportStatus.t()) :: Ecto.Query.t() @spec list_reports_query(ReportStatus.t()) :: Ecto.Query.t()
defp list_reports_query(status) do defp list_reports_query(status) do
from( Report
r in Report, |> preload([:reported, :reporter, :manager, :event, :comments, :notes])
preload: [:reported, :reporter, :manager, :event, :comments, :notes], |> where([r], r.status == ^status)
where: 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 end
@spec count_reports_query :: Ecto.Query.t() @spec count_reports_query :: Ecto.Query.t()

View File

@ -272,23 +272,14 @@ defmodule Mobilizon.Users do
@doc """ @doc """
Returns the list of users. Returns the list of users.
""" """
@spec list_users(String.t(), integer | nil, integer | nil, atom, atom) :: @spec list_users(Keyword.t()) :: Page.t(User.t())
Page.t(User.t()) def list_users(options) do
def list_users(email, page, limit \\ nil, sort, direction)
def list_users("", page, limit, sort, direction) do
User 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]) |> 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
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)
end end
@doc """ @doc """
@ -527,4 +518,16 @@ defmodule Mobilizon.Users do
defp update_user_default_actor_query(user_id) do defp update_user_default_actor_query(user_id) do
where(User, [u], u.id == ^user_id) where(User, [u], u.id == ^user_id)
end 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 end

View File

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

View File

@ -8,7 +8,12 @@ defmodule Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker do
@impl Oban.Worker @impl Oban.Worker
def perform(%Job{}) do 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() CleanUnconfirmedUsers.clean()
end end
end end

View File

@ -41,7 +41,7 @@ defmodule Mobilizon.Service.Workers.Helper do
alias Oban.Job 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()} {:ok, Job.t()} | {:error, Ecto.Changeset.t()}
def enqueue(operation, params, worker_args \\ []) do def enqueue(operation, params, worker_args \\ []) do
params = Map.merge(%{"op" => operation}, params) 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) |> assign(:report, report)
|> render(:report) |> render(:report)
end 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 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 "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) %> <%= Routes.page_url(Mobilizon.Web.Endpoint, :user_email_validation, @token) %>
<%= gettext "If you didn't trigger the change yourself, please ignore this message." %> <%= 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