Merge branch 'tailwind2' into 'main'
Various admin improvements Closes #973 See merge request framasoft/mobilizon!1152
This commit is contained in:
commit
e6b95a43d1
66
.devcontainer/Dockerfile
Normal file
66
.devcontainer/Dockerfile
Normal 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 ...
|
44
.devcontainer/devcontainer.json
Normal file
44
.devcontainer/devcontainer.json
Normal 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"
|
||||
}
|
46
.devcontainer/docker-compose.yml
Normal file
46
.devcontainer/docker-compose.yml
Normal 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
2
.tool-versions
Normal file
@ -0,0 +1,2 @@
|
||||
elixir 1.13
|
||||
erlang 24.2
|
@ -290,6 +290,7 @@ config :mobilizon, Oban,
|
||||
crontab: [
|
||||
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background},
|
||||
{"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background},
|
||||
{"36 * * * *", Mobilizon.Service.Workers.RefreshInstances, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background},
|
||||
{"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background},
|
||||
|
@ -16,6 +16,7 @@
|
||||
"@absinthe/socket-apollo-link": "^0.2.1",
|
||||
"@apollo/client": "^3.3.16",
|
||||
"@mdi/font": "^6.1.95",
|
||||
"@tailwindcss/line-clamp": "^0.3.0",
|
||||
"@tiptap/core": "^2.0.0-beta.41",
|
||||
"@tiptap/extension-blockquote": "^2.0.0-beta.25",
|
||||
"@tiptap/extension-bold": "^2.0.0-beta.24",
|
||||
@ -41,6 +42,7 @@
|
||||
"@vue-a11y/skip-to": "^2.1.2",
|
||||
"@vue/apollo-option": "4.0.0-alpha.11",
|
||||
"apollo-absinthe-upload-link": "^1.5.0",
|
||||
"autoprefixer": "^10",
|
||||
"blurhash": "^1.1.3",
|
||||
"buefy": "^0.9.0",
|
||||
"bulma-divider": "^0.2.0",
|
||||
@ -57,8 +59,10 @@
|
||||
"ngeohash": "^0.6.3",
|
||||
"p-debounce": "^4.0.0",
|
||||
"phoenix": "^1.6",
|
||||
"postcss": "^8",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"sanitize-html": "^2.5.3",
|
||||
"tailwindcss": "^3",
|
||||
"tippy.js": "^6.2.3",
|
||||
"unfetch": "^4.2.0",
|
||||
"v-tooltip": "^2.1.3",
|
||||
@ -112,6 +116,7 @@
|
||||
"sass-loader": "^12.0.0",
|
||||
"ts-jest": "27",
|
||||
"typescript": "~4.4.3",
|
||||
"vue-cli-plugin-tailwind": "^3.0.0-beta.0",
|
||||
"vue-i18n-extract": "^2.0.4",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"webpack-cli": "^4.7.0"
|
||||
|
6
js/postcss.config.js
Normal file
6
js/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
@ -216,7 +216,11 @@ export default class App extends Vue {
|
||||
// Set the focus to the router view
|
||||
// https://marcus.io/blog/accessible-routing-vuejs
|
||||
setTimeout(() => {
|
||||
const focusTarget = this.routerView?.$el as HTMLElement;
|
||||
const focusTarget = (
|
||||
this.routerView?.$refs?.componentFocusTarget !== undefined
|
||||
? this.routerView?.$refs?.componentFocusTarget
|
||||
: this.routerView?.$el
|
||||
) as HTMLElement;
|
||||
if (focusTarget) {
|
||||
// Make focustarget programmatically focussable
|
||||
focusTarget.setAttribute("tabindex", "-1");
|
||||
|
@ -70,6 +70,9 @@ export const typePolicies: TypePolicies = {
|
||||
participantStats: { merge: replaceMergePolicy },
|
||||
},
|
||||
},
|
||||
Instance: {
|
||||
keyFields: ["domain"],
|
||||
},
|
||||
RootQueryType: {
|
||||
fields: {
|
||||
relayFollowers: paginatedLimitPagination<IFollower>(),
|
||||
|
9
js/src/assets/logo.svg
Normal file
9
js/src/assets/logo.svg
Normal 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 |
5
js/src/assets/tailwind.css
Normal file
5
js/src/assets/tailwind.css
Normal file
@ -0,0 +1,5 @@
|
||||
@tailwind base;
|
||||
|
||||
@tailwind components;
|
||||
|
||||
@tailwind utilities;
|
@ -7,10 +7,6 @@
|
||||
@import "styles/vue-announcer.scss";
|
||||
@import "styles/vue-skip-to.scss";
|
||||
|
||||
// a {
|
||||
// color: $violet-2;
|
||||
// }
|
||||
|
||||
a.out,
|
||||
.content a,
|
||||
.ProseMirror a {
|
||||
@ -19,18 +15,10 @@ a.out,
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
// input.input {
|
||||
// border-color: $input-border-color !important;
|
||||
// }
|
||||
|
||||
.section {
|
||||
padding: 1rem 1% 4rem;
|
||||
}
|
||||
|
||||
figure img.is-rounded {
|
||||
border: 1px solid #cdcaea;
|
||||
}
|
||||
|
||||
$color-black: #000;
|
||||
|
||||
.mention {
|
||||
|
@ -1,31 +1,75 @@
|
||||
<template>
|
||||
<div class="media" style="align-items: top" dir="auto">
|
||||
<div class="media-left">
|
||||
<figure class="image is-32x32" v-if="actor.avatar">
|
||||
<img class="is-rounded" :src="actor.avatar.url" alt="" />
|
||||
<div
|
||||
class="w-80 bg-white rounded-lg shadow-md p-4 sm:p-8 flex items-center space-x-4 flex-col items-center pb-10"
|
||||
>
|
||||
<figure class="w-12 h-12" v-if="actor.avatar">
|
||||
<img
|
||||
class="rounded-lg"
|
||||
:src="actor.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<b-icon
|
||||
v-else
|
||||
size="is-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>
|
||||
<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 class="media-content">
|
||||
<p>
|
||||
{{ actor.name || `@${usernameWithDomain(actor)}` }}
|
||||
</p>
|
||||
<p class="has-text-grey-dark" v-if="actor.name">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
|
||||
{{ displayName(actor) }}
|
||||
</h5>
|
||||
<p class="text-gray-500 truncate" v-if="actor.name">
|
||||
<span dir="ltr">@{{ usernameWithDomain(actor) }}</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="full"
|
||||
class="summary"
|
||||
class="line-clamp-3"
|
||||
:class="{ limit: limit }"
|
||||
v-html="actor.summary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||
import { IActor, usernameWithDomain } from "../../types/actor";
|
||||
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
|
||||
|
||||
@Component
|
||||
export default class ActorCard extends Vue {
|
||||
@ -38,135 +82,7 @@ export default class ActorCard extends Vue {
|
||||
@Prop({ required: false, type: Boolean, default: true }) limit!: boolean;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.summary.limit {
|
||||
max-width: 25rem;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/styles/_mixins" as *;
|
||||
|
||||
.media {
|
||||
.media-left {
|
||||
margin-right: initial;
|
||||
@include margin-right(1rem);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
display: block !important;
|
||||
z-index: 10000;
|
||||
|
||||
.tooltip-inner {
|
||||
background: black;
|
||||
color: white;
|
||||
border-radius: 16px;
|
||||
padding: 5px 10px 4px;
|
||||
}
|
||||
|
||||
.tooltip-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
position: absolute;
|
||||
margin: 5px;
|
||||
border-color: black;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&[x-placement^="top"] {
|
||||
margin-bottom: 5px;
|
||||
|
||||
.tooltip-arrow {
|
||||
border-width: 5px 5px 0 5px;
|
||||
border-left-color: transparent !important;
|
||||
border-right-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
bottom: -5px;
|
||||
left: calc(50% - 5px);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[x-placement^="bottom"] {
|
||||
margin-top: 5px;
|
||||
|
||||
.tooltip-arrow {
|
||||
border-width: 0 5px 5px 5px;
|
||||
border-left-color: transparent !important;
|
||||
border-right-color: transparent !important;
|
||||
border-top-color: transparent !important;
|
||||
top: -5px;
|
||||
left: calc(50% - 5px);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[x-placement^="right"] {
|
||||
@include margin-left(5px);
|
||||
|
||||
.tooltip-arrow {
|
||||
border-width: 5px 5px 5px 0;
|
||||
border-left-color: transparent !important;
|
||||
border-top-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
left: -5px;
|
||||
top: calc(50% - 5px);
|
||||
@include margin-left(0);
|
||||
@include margin-right(0);
|
||||
}
|
||||
}
|
||||
|
||||
&[x-placement^="left"] {
|
||||
@include margin-right(5px);
|
||||
|
||||
.tooltip-arrow {
|
||||
border-width: 5px 0 5px 5px;
|
||||
border-top-color: transparent !important;
|
||||
border-right-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
right: -5px;
|
||||
top: calc(50% - 5px);
|
||||
@include margin-left(0);
|
||||
@include margin-right(0);
|
||||
}
|
||||
}
|
||||
|
||||
&.popover {
|
||||
$color: #f9f9f9;
|
||||
|
||||
.popover-inner {
|
||||
background: lighten($background-color, 65%);
|
||||
color: black;
|
||||
padding: 24px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 5px 30px rgba(black, 0.1);
|
||||
}
|
||||
|
||||
.popover-arrow {
|
||||
border-color: $color;
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-hidden="true"] {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, visibility 0.15s;
|
||||
}
|
||||
|
||||
&[aria-hidden="false"] {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -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>
|
@ -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>
|
@ -34,12 +34,6 @@
|
||||
class="metadata-organized-by"
|
||||
:title="$t('Organized by')"
|
||||
>
|
||||
<popover-actor-card
|
||||
:actor="event.organizerActor"
|
||||
v-if="!event.attributedTo"
|
||||
>
|
||||
<actor-card :actor="event.organizerActor" />
|
||||
</popover-actor-card>
|
||||
<router-link
|
||||
v-if="event.attributedTo"
|
||||
:to="{
|
||||
@ -49,23 +43,19 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<popover-actor-card
|
||||
:actor="event.attributedTo"
|
||||
<actor-card
|
||||
v-if="
|
||||
!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent
|
||||
"
|
||||
>
|
||||
<actor-card :actor="event.attributedTo" />
|
||||
</popover-actor-card>
|
||||
:actor="event.attributedTo"
|
||||
/>
|
||||
<actor-card v-else :actor="event.organizerActor" />
|
||||
</router-link>
|
||||
|
||||
<popover-actor-card
|
||||
<actor-card
|
||||
:actor="contact"
|
||||
v-for="contact in event.contacts"
|
||||
:key="contact.id"
|
||||
>
|
||||
<actor-card :actor="contact" />
|
||||
</popover-actor-card>
|
||||
/>
|
||||
</event-metadata-block>
|
||||
<event-metadata-block
|
||||
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"
|
||||
|
@ -78,7 +78,7 @@
|
||||
/>
|
||||
<SettingMenuItem
|
||||
:title="$t('Federation')"
|
||||
:to="{ name: RouteName.RELAYS }"
|
||||
:to="{ name: RouteName.INSTANCES }"
|
||||
/>
|
||||
</SettingMenuSection>
|
||||
</ul>
|
||||
|
69
js/src/components/Utils/Breadcrumbs.vue
Normal file
69
js/src/components/Utils/Breadcrumbs.vue
Normal 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>
|
@ -70,6 +70,67 @@ export const RELAY_FOLLOWINGS = gql`
|
||||
${RELAY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const INSTANCE_FRAGMENT = gql`
|
||||
fragment InstanceFragment on Instance {
|
||||
domain
|
||||
hasRelay
|
||||
followerStatus
|
||||
followedStatus
|
||||
eventCount
|
||||
personCount
|
||||
groupCount
|
||||
followersCount
|
||||
followingsCount
|
||||
reportsCount
|
||||
mediaSize
|
||||
}
|
||||
`;
|
||||
|
||||
export const INSTANCE = gql`
|
||||
query instance($domain: ID!) {
|
||||
instance(domain: $domain) {
|
||||
...InstanceFragment
|
||||
}
|
||||
}
|
||||
${INSTANCE_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const INSTANCES = gql`
|
||||
query Instances(
|
||||
$page: Int
|
||||
$limit: Int
|
||||
$orderBy: InstancesSortFields
|
||||
$direction: String
|
||||
$filterDomain: String
|
||||
$filterFollowStatus: InstanceFilterFollowStatus
|
||||
$filterSuspendStatus: InstanceFilterSuspendStatus
|
||||
) {
|
||||
instances(
|
||||
page: $page
|
||||
limit: $limit
|
||||
orderBy: $orderBy
|
||||
direction: $direction
|
||||
filterDomain: $filterDomain
|
||||
filterFollowStatus: $filterFollowStatus
|
||||
filterSuspendStatus: $filterSuspendStatus
|
||||
) {
|
||||
total
|
||||
elements {
|
||||
...InstanceFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${INSTANCE_FRAGMENT}
|
||||
`;
|
||||
export const ADD_INSTANCE = gql`
|
||||
mutation addInstance($domain: String!) {
|
||||
addInstance(domain: $domain) {
|
||||
...InstanceFragment
|
||||
}
|
||||
}
|
||||
${INSTANCE_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const ADD_RELAY = gql`
|
||||
mutation addRelay($address: String!) {
|
||||
addRelay(address: $address) {
|
||||
@ -190,3 +251,26 @@ export const SAVE_ADMIN_SETTINGS = gql`
|
||||
}
|
||||
${ADMIN_SETTINGS_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const ADMIN_UPDATE_USER = gql`
|
||||
mutation AdminUpdateUser(
|
||||
$id: ID!
|
||||
$email: String
|
||||
$role: UserRole
|
||||
$confirmed: Boolean
|
||||
$notify: Boolean
|
||||
) {
|
||||
adminUpdateUser(
|
||||
id: $id
|
||||
email: $email
|
||||
role: $role
|
||||
confirmed: $confirmed
|
||||
notify: $notify
|
||||
) {
|
||||
id
|
||||
email
|
||||
role
|
||||
confirmedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -2,8 +2,13 @@ import gql from "graphql-tag";
|
||||
import { ACTOR_FRAGMENT } from "./actor";
|
||||
|
||||
export const REPORTS = gql`
|
||||
query Reports($status: ReportStatus, $page: Int, $limit: Int) {
|
||||
reports(status: $status, page: $page, limit: $limit) {
|
||||
query Reports(
|
||||
$status: ReportStatus
|
||||
$domain: String
|
||||
$page: Int
|
||||
$limit: Int
|
||||
) {
|
||||
reports(status: $status, domain: $domain, page: $page, limit: $limit) {
|
||||
total
|
||||
elements {
|
||||
id
|
||||
|
@ -209,14 +209,30 @@ export const UPDATE_ACTIVITY_SETTING = gql`
|
||||
`;
|
||||
|
||||
export const LIST_USERS = gql`
|
||||
query ListUsers($email: String, $page: Int, $limit: Int) {
|
||||
users(email: $email, page: $page, limit: $limit) {
|
||||
query ListUsers(
|
||||
$email: String
|
||||
$currentSignInIp: String
|
||||
$page: Int
|
||||
$limit: Int
|
||||
$sort: SortableUserField
|
||||
$direction: SortDirection
|
||||
) {
|
||||
users(
|
||||
email: $email
|
||||
currentSignInIp: $currentSignInIp
|
||||
page: $page
|
||||
limit: $limit
|
||||
sort: $sort
|
||||
direction: $direction
|
||||
) {
|
||||
total
|
||||
elements {
|
||||
id
|
||||
email
|
||||
locale
|
||||
confirmedAt
|
||||
currentSignInIp
|
||||
currentSignInAt
|
||||
disabled
|
||||
actors {
|
||||
...ActorFragment
|
||||
|
@ -1260,5 +1260,52 @@
|
||||
"This profile was not found": "This profile was not found",
|
||||
"Back to profile list": "Back to profile list",
|
||||
"This user was not found": "This user was not found",
|
||||
"Back to user list": "Back to user list"
|
||||
"Back to user list": "Back to user list",
|
||||
"Stop following instance": "Stop following instance",
|
||||
"Follow instance": "Follow instance",
|
||||
"Accept follow": "Accept follow",
|
||||
"Reject follow": "Reject follow",
|
||||
"This instance doesn't follow yours.": "This instance doesn't follow yours.",
|
||||
"Only Mobilizon instances can be followed": "",
|
||||
"Follow a new instance": "Follow a new instance",
|
||||
"Follow status": "Follow status",
|
||||
"All": "All",
|
||||
"Following": "Following",
|
||||
"Followed": "Followed",
|
||||
"Followed, pending response": "Followed, pending response",
|
||||
"Follows us": "Follows us",
|
||||
"Follows us, pending approval": "Follows us, pending approval",
|
||||
"No instance found.": "No instance found.",
|
||||
"No instances match this filter. Try resetting filter fields?": "No instances match this filter. Try resetting filter fields?",
|
||||
"You haven't interacted with other instances yet.": "You haven't interacted with other instances yet.",
|
||||
"mobilizon-instance.tld": "mobilizon-instance.tld",
|
||||
"Report status": "Report status",
|
||||
"access the corresponding account": "access the corresponding account",
|
||||
"Organized events": "Organized events",
|
||||
"Memberships": "Memberships",
|
||||
"This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.": "This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.",
|
||||
"Total number of participations": "Total number of participations",
|
||||
"Uploaded media total size": "Uploaded media total size",
|
||||
"0 Bytes": "0 Bytes",
|
||||
"Change email": "Change email",
|
||||
"Confirm user": "Confirm user",
|
||||
"Change role": "Change role",
|
||||
"The user has been disabled": "The user has been disabled",
|
||||
"This user doesn't have any profiles": "This user doesn't have any profiles",
|
||||
"Edit user email": "Edit user email",
|
||||
"Change user email": "Change user email",
|
||||
"Previous email": "Previous email",
|
||||
"Notify the user of the change": "Notify the user of the change",
|
||||
"Change user role": "Change user role",
|
||||
"Suspend the account?": "Suspend the account?",
|
||||
"Do you really want to suspend this account? All of the user's profiles will be deleted.": "Do you really want to suspend this account? All of the user's profiles will be deleted.",
|
||||
"Suspend the account": "Suspend the account",
|
||||
"No user matches the filter": "No user matches the filter",
|
||||
"new@email.com": "new@email.com",
|
||||
"Other users with the same email domain": "Other users with the same email domain",
|
||||
"Other users with the same IP address": "Other users with the same IP address",
|
||||
"IP Address": "IP Address",
|
||||
"Last seen on": "Last seen on",
|
||||
"No user matches the filters": "No user matches the filters",
|
||||
"Reset filters": "Reset filters"
|
||||
}
|
||||
|
@ -288,7 +288,7 @@
|
||||
"Either the participation request has already been validated, either the validation token is incorrect.": "Soit la demande de participation a déjà été validée, soit le jeton de validation est incorrect.",
|
||||
"Element title": "Titre de l'élement",
|
||||
"Element value": "Valeur de l'élement",
|
||||
"Email": "Email",
|
||||
"Email": "Courriel",
|
||||
"Email address": "Adresse email",
|
||||
"Email validate": "Validation de l'email",
|
||||
"Emails usually don't contain capitals, make sure you haven't made a typo.": "Les emails ne contiennent d'ordinaire pas de capitales, assurez-vous de n'avoir pas fait de faute de frappe.",
|
||||
@ -1260,5 +1260,52 @@
|
||||
"{timezoneLongName} ({timezoneShortName})": "{timezoneLongName} ({timezoneShortName})",
|
||||
"{title} ({count} todos)": "{title} ({count} todos)",
|
||||
"{username} was invited to {group}": "{username} a été invité à {group}",
|
||||
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
|
||||
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
|
||||
"Stop following instance": "Arrêter de suivre l'instance",
|
||||
"Follow instance": "Suivre l'instance",
|
||||
"Accept follow": "Accepter le suivi",
|
||||
"Reject follow": "Rejetter le suivi",
|
||||
"This instance doesn't follow yours.": "Cette instance ne suit pas la vôtre.",
|
||||
"Only Mobilizon instances can be followed": "Seules les instances Mobilizon peuvent être suivies",
|
||||
"Follow a new instance": "Suivre une nouvelle instance",
|
||||
"Follow status": "Statut du suivi",
|
||||
"All": "Toutes",
|
||||
"Following": "Suivantes",
|
||||
"Followed": "Suivies",
|
||||
"Followed, pending response": "Suivie, en attente de la réponse",
|
||||
"Follows us": "Nous suit",
|
||||
"Follows us, pending approval": "Nous suit, en attente de validation",
|
||||
"No instance found": "Aucune instance trouvée",
|
||||
"No instances match this filter. Try resetting filter fields?": "Aucune instance ne correspond à ce filtre. Essayer de remettre à zéro les champs des filtres ?",
|
||||
"You haven't interacted with other instances yet.": "Vous n'avez interagi avec encore aucune autre instance.",
|
||||
"mobilizon-instance.tld": "instance-mobilizon.tld",
|
||||
"Report status": "Statut du signalement",
|
||||
"access the corresponding account": "accéder au compte correspondant",
|
||||
"Organized events": "Événements organisés",
|
||||
"Memberships": "Adhésions",
|
||||
"This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.": "Ce profil se situe sur cette instance, vous devez donc {access_the_corresponding_account} afin de le suspendre.",
|
||||
"Total number of participations": "Nombre total de participations",
|
||||
"Uploaded media total size": "Taille totale des médias téléversés",
|
||||
"0 Bytes": "0 octets",
|
||||
"Change email": "Changer l'email",
|
||||
"Confirm user": "Confirmer l'utilisateur⋅ice",
|
||||
"Change role": "Changer le role",
|
||||
"The user has been disabled": "L'utilisateur⋅ice a été désactivé",
|
||||
"This user doesn't have any profiles": "Cet utilisateur⋅ice n'a aucun profil",
|
||||
"Edit user email": "Éditer l'email de l'utilisateur⋅ice",
|
||||
"Change user email": "Modifier l'email de l'utilisateur⋅ice",
|
||||
"Previous email": "Email précédent",
|
||||
"Notify the user of the change": "Notifier l'utilisateur du changement",
|
||||
"Change user role": "Changer le role de l'utilisateur",
|
||||
"Suspend the account?": "Suspendre le compte ?",
|
||||
"Do you really want to suspend this account? All of the user's profiles will be deleted.": "Voulez-vous vraiment suspendre ce compte ? Tous les profils de cet⋅te utilisateur⋅ice seront supprimés.",
|
||||
"Suspend the account": "Suspendre le compte",
|
||||
"No user matches the filter": "Aucun⋅e utilisateur⋅ice ne correspond au filtre",
|
||||
"new@email.com": "nouvel@email.com",
|
||||
"Other users with the same email domain": "Autres utilisateur⋅ices avec le même domaine de courriel",
|
||||
"Other users with the same IP address": "Autres utilisateur⋅ices avec la même adresse IP",
|
||||
"IP Address": "Adresse IP",
|
||||
"Last seen on": "Vu pour la dernière fois",
|
||||
"No user matches the filters": "Aucun⋅e utilisateur⋅ice ne correspond aux filtres",
|
||||
"Reset filters": "Réinitialiser les filtres"
|
||||
}
|
||||
|
@ -12,7 +12,9 @@ import { NotifierPlugin } from "./plugins/notifier";
|
||||
import filters from "./filters";
|
||||
import { i18n } from "./utils/i18n";
|
||||
import apolloProvider from "./vue-apollo";
|
||||
import Breadcrumbs from "@/components/Utils/Breadcrumbs.vue";
|
||||
import "./registerServiceWorker";
|
||||
import "./assets/tailwind.css";
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
@ -24,6 +26,7 @@ Vue.use(VueScrollTo);
|
||||
Vue.use(VTooltip);
|
||||
Vue.use(VueAnnouncer);
|
||||
Vue.use(VueSkipTo);
|
||||
Vue.component("breadcrumbs-nav", Breadcrumbs);
|
||||
|
||||
// Register the router hooks with their names
|
||||
Component.registerHooks([
|
||||
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
@ -11,9 +11,8 @@ export enum SettingsRouteName {
|
||||
ADMIN = "ADMIN",
|
||||
ADMIN_DASHBOARD = "ADMIN_DASHBOARD",
|
||||
ADMIN_SETTINGS = "ADMIN_SETTINGS",
|
||||
RELAYS = "Relays",
|
||||
RELAY_FOLLOWINGS = "Followings",
|
||||
RELAY_FOLLOWERS = "Followers",
|
||||
INSTANCES = "INSTANCES",
|
||||
INSTANCE = "INSTANCE",
|
||||
USERS = "USERS",
|
||||
PROFILES = "PROFILES",
|
||||
ADMIN_PROFILE = "ADMIN_PROFILE",
|
||||
@ -21,7 +20,7 @@ export enum SettingsRouteName {
|
||||
ADMIN_GROUPS = "ADMIN_GROUPS",
|
||||
ADMIN_GROUP_PROFILE = "ADMIN_GROUP_PROFILE",
|
||||
MODERATION = "MODERATION",
|
||||
REPORTS = "Reports",
|
||||
REPORTS = "REPORTS",
|
||||
REPORT = "Report",
|
||||
REPORT_LOGS = "Logs",
|
||||
CREATE_IDENTITY = "CreateIdentity",
|
||||
@ -199,44 +198,35 @@ export const settingsRoutes: RouteConfig[] = [
|
||||
meta: { requiredAuth: true, announcer: { skip: true } },
|
||||
},
|
||||
{
|
||||
path: "admin/relays",
|
||||
name: SettingsRouteName.RELAYS,
|
||||
redirect: { name: SettingsRouteName.RELAY_FOLLOWINGS },
|
||||
path: "admin/instances",
|
||||
name: SettingsRouteName.INSTANCES,
|
||||
component: (): Promise<ImportedComponent> =>
|
||||
import(/* webpackChunkName: "Follows" */ "@/views/Admin/Follows.vue"),
|
||||
meta: { requiredAuth: true, announcer: { skip: true } },
|
||||
children: [
|
||||
{
|
||||
path: "followings",
|
||||
name: SettingsRouteName.RELAY_FOLLOWINGS,
|
||||
component: (): Promise<ImportedComponent> =>
|
||||
import(
|
||||
/* webpackChunkName: "Followings" */ "@/components/Admin/Followings.vue"
|
||||
),
|
||||
meta: {
|
||||
requiredAuth: true,
|
||||
announcer: {
|
||||
message: (): string => i18n.t("Followings") as string,
|
||||
},
|
||||
},
|
||||
import(
|
||||
/* webpackChunkName: "Instances" */ "@/views/Admin/Instances.vue"
|
||||
),
|
||||
meta: {
|
||||
requiredAuth: true,
|
||||
announcer: {
|
||||
message: (): string => i18n.t("Instances") as string,
|
||||
},
|
||||
{
|
||||
path: "followers",
|
||||
name: SettingsRouteName.RELAY_FOLLOWERS,
|
||||
component: (): Promise<ImportedComponent> =>
|
||||
import(
|
||||
/* webpackChunkName: "Followers" */ "@/components/Admin/Followers.vue"
|
||||
),
|
||||
meta: {
|
||||
requiredAuth: true,
|
||||
announcer: {
|
||||
message: (): string => i18n.t("Followers") as string,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "admin/instances/:domain",
|
||||
name: SettingsRouteName.INSTANCE,
|
||||
component: (): Promise<ImportedComponent> =>
|
||||
import(
|
||||
/* webpackChunkName: "Instance" */ "@/views/Admin/Instance.vue"
|
||||
),
|
||||
props: true,
|
||||
meta: {
|
||||
requiredAuth: true,
|
||||
announcer: {
|
||||
message: (): string => i18n.t("Instance") as string,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/moderation",
|
||||
name: SettingsRouteName.MODERATION,
|
||||
@ -244,7 +234,7 @@ export const settingsRoutes: RouteConfig[] = [
|
||||
meta: { requiredAuth: true, announcer: { skip: true } },
|
||||
},
|
||||
{
|
||||
path: "/moderation/reports/:filter?",
|
||||
path: "/moderation/reports",
|
||||
name: SettingsRouteName.REPORTS,
|
||||
component: (): Promise<ImportedComponent> =>
|
||||
import(
|
||||
|
@ -276,3 +276,15 @@ export enum EventMetadataCategories {
|
||||
BOOKING = "BOOKING",
|
||||
VIDEO_CONFERENCE = "VIDEO_CONFERENCE",
|
||||
}
|
||||
|
||||
export enum InstanceFilterFollowStatus {
|
||||
ALL = "ALL",
|
||||
FOLLOWING = "FOLLOWING",
|
||||
FOLLOWED = "FOLLOWED",
|
||||
}
|
||||
|
||||
export enum InstanceFollowStatus {
|
||||
APPROVED = "APPROVED",
|
||||
PENDING = "PENDING",
|
||||
NONE = "NONE",
|
||||
}
|
||||
|
14
js/src/types/instance.model.ts
Normal file
14
js/src/types/instance.model.ts
Normal 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;
|
||||
}
|
@ -19,8 +19,8 @@ function localeShortWeekDayNames(): string[] {
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/18650828/10204399
|
||||
function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
function formatBytes(bytes: number, decimals = 2, zero = "0 Bytes"): string {
|
||||
if (bytes === 0) return zero;
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
|
@ -139,3 +139,17 @@ $subtitle-sup-size: 15px;
|
||||
$breadcrumb-item-color: $primary;
|
||||
$checkbox-background-color: #fff;
|
||||
$title-color: $violet-3;
|
||||
|
||||
:root {
|
||||
--color-primary: 30 125 151;
|
||||
--color-secondary: 255 213 153;
|
||||
--color-violet-title: 66 64 86;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-primary: 30 125 151;
|
||||
--color-secondary: 255 213 153;
|
||||
--color-violet-title: 66 64 86;
|
||||
}
|
||||
}
|
||||
|
@ -1,28 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.IDENTITIES }">{{
|
||||
$t("Profiles")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active" v-if="isUpdate && identity">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.UPDATE_IDENTITY,
|
||||
params: { identityName: identity.preferredUsername },
|
||||
}"
|
||||
>{{ identity.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="is-active" v-else>
|
||||
<router-link :to="{ name: RouteName.CREATE_IDENTITY }">{{
|
||||
$t("New profile")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav :links="breadcrumbsLinks" />
|
||||
<div class="root" v-if="identity">
|
||||
<h1 class="title">
|
||||
<span v-if="isUpdate">{{ identity.displayName() }}</span>
|
||||
@ -253,6 +231,7 @@ import { ServerParseError } from "@apollo/client/link/http";
|
||||
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
|
||||
import pick from "lodash/pick";
|
||||
import { ActorType } from "@/types/enums";
|
||||
import { Location } from "vue-router";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@ -670,5 +649,29 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
|
||||
this.oldDisplayName = null;
|
||||
this.avatarFile = null;
|
||||
}
|
||||
|
||||
get breadcrumbsLinks(): (Location & { text: string })[] {
|
||||
const links = [
|
||||
{
|
||||
name: RouteName.IDENTITIES,
|
||||
params: {},
|
||||
text: this.$t("Profiles") as string,
|
||||
},
|
||||
];
|
||||
if (this.isUpdate && this.identity) {
|
||||
links.push({
|
||||
name: RouteName.UPDATE_IDENTITY,
|
||||
params: { identityName: this.identity.preferredUsername },
|
||||
text: this.identity.name,
|
||||
});
|
||||
} else {
|
||||
links.push({
|
||||
name: RouteName.CREATE_IDENTITY,
|
||||
params: {},
|
||||
text: this.$t("New profile") as string,
|
||||
});
|
||||
}
|
||||
return links;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -1,31 +1,19 @@
|
||||
<template>
|
||||
<div v-if="group" class="section">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.ADMIN }">{{
|
||||
$t("Admin")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_GROUPS,
|
||||
}"
|
||||
>{{ $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>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: $t('Admin') },
|
||||
{
|
||||
name: RouteName.ADMIN_GROUPS,
|
||||
text: $t('Groups'),
|
||||
},
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
params: { id: group.id },
|
||||
text: displayName(group),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div class="actor-card">
|
||||
<p v-if="group.suspended">
|
||||
<actor-card
|
||||
@ -305,7 +293,11 @@ import { formatBytes } from "@/utils/datetime";
|
||||
import { MemberRole } from "@/types/enums";
|
||||
import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
|
||||
import { IGroup } from "../../types/actor";
|
||||
import { usernameWithDomain, IActor } from "../../types/actor/actor.model";
|
||||
import {
|
||||
usernameWithDomain,
|
||||
displayName,
|
||||
IActor,
|
||||
} from "../../types/actor/actor.model";
|
||||
import RouteName from "../../router/name";
|
||||
import ActorCard from "../../components/Account/ActorCard.vue";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
@ -359,6 +351,8 @@ export default class AdminGroupProfile extends Vue {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
|
||||
|
@ -1,32 +1,21 @@
|
||||
<template>
|
||||
<div v-if="person" class="section">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.ADMIN }">{{
|
||||
$t("Admin")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.PROFILES,
|
||||
}"
|
||||
>{{ $t("Profiles") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.PROFILES,
|
||||
params: { id: person.id },
|
||||
}"
|
||||
>{{ person.name || person.preferredUsername }}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="actor-card">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: $t('Admin') },
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
text: $t('Profiles'),
|
||||
},
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
params: { id: person.id },
|
||||
text: displayName(person),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<actor-card
|
||||
:actor="person"
|
||||
:full="true"
|
||||
@ -34,41 +23,84 @@
|
||||
:limit="false"
|
||||
/>
|
||||
</div>
|
||||
<table v-if="metadata.length > 0" class="table is-fullwidth">
|
||||
<tbody>
|
||||
<tr v-for="{ key, value, link } in metadata" :key="key">
|
||||
<td>{{ key }}</td>
|
||||
<td v-if="link">
|
||||
<router-link :to="link">
|
||||
{{ value }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td v-else>{{ value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="buttons">
|
||||
<b-button
|
||||
@click="suspendProfile"
|
||||
v-if="person.domain && !person.suspended"
|
||||
type="is-primary"
|
||||
>{{ $t("Suspend") }}</b-button
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="text-lg font-bold">{{ $t("Details") }}</h2>
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block py-2 min-w-full sm:px-2 lg:px-8">
|
||||
<div class="overflow-hidden shadow-md sm:rounded-lg">
|
||||
<table v-if="metadata.length > 0" class="min-w-full">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="{ key, value, link } in metadata"
|
||||
:key="key"
|
||||
class="odd:bg-white even:bg-gray-50 border-b"
|
||||
>
|
||||
<td class="py-4 px-2 whitespace-nowrap">
|
||||
{{ key }}
|
||||
</td>
|
||||
<td
|
||||
v-if="link"
|
||||
class="py-4 px-2 text-sm text-gray-500 whitespace-nowrap"
|
||||
>
|
||||
<router-link :to="link">
|
||||
{{ value }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td
|
||||
v-else
|
||||
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
|
||||
@click="unsuspendProfile"
|
||||
v-if="person.domain && person.suspended"
|
||||
type="is-primary"
|
||||
>{{ $t("Unsuspend") }}</b-button
|
||||
>
|
||||
</div>
|
||||
<section>
|
||||
<h2 class="subtitle">
|
||||
{{
|
||||
$tc("{number} organized events", person.organizedEvents.total, {
|
||||
number: person.organizedEvents.total,
|
||||
})
|
||||
}}
|
||||
</h2>
|
||||
<i18n
|
||||
path="This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it."
|
||||
>
|
||||
<template #access_the_corresponding_account>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: person.user.id },
|
||||
}"
|
||||
>{{ $t("access the corresponding account") }}</router-link
|
||||
>
|
||||
</template>
|
||||
</i18n>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="text-lg font-bold">{{ $t("Organized events") }}</h2>
|
||||
<b-table
|
||||
:data="person.organizedEvents.elements"
|
||||
:loading="$apollo.queries.person.loading"
|
||||
@ -104,14 +136,8 @@
|
||||
</template>
|
||||
</b-table>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="subtitle">
|
||||
{{
|
||||
$tc("{number} participations", person.participations.total, {
|
||||
number: person.participations.total,
|
||||
})
|
||||
}}
|
||||
</h2>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="text-lg font-bold">{{ $t("Participations") }}</h2>
|
||||
<b-table
|
||||
:data="
|
||||
person.participations.elements.map(
|
||||
@ -151,14 +177,8 @@
|
||||
</template>
|
||||
</b-table>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="subtitle">
|
||||
{{
|
||||
$tc("{number} memberships", person.memberships.total, {
|
||||
number: person.memberships.total,
|
||||
})
|
||||
}}
|
||||
</h2>
|
||||
<section class="mt-4 mb-3">
|
||||
<h2 class="text-lg font-bold">{{ $t("Memberships") }}</h2>
|
||||
<b-table
|
||||
:data="person.memberships.elements"
|
||||
:loading="$apollo.loading"
|
||||
@ -279,7 +299,7 @@ import {
|
||||
UNSUSPEND_PROFILE,
|
||||
} from "../../graphql/actor";
|
||||
import { IPerson } from "../../types/actor";
|
||||
import { usernameWithDomain } from "../../types/actor/actor.model";
|
||||
import { displayName, usernameWithDomain } from "../../types/actor/actor.model";
|
||||
import RouteName from "../../router/name";
|
||||
import ActorCard from "../../components/Account/ActorCard.vue";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
@ -334,6 +354,8 @@ export default class AdminProfile extends Vue {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
|
||||
@ -384,6 +406,12 @@ export default class AdminProfile extends Vue {
|
||||
{
|
||||
key: this.$t("Domain") as string,
|
||||
value: this.person.domain ? this.person.domain : this.$t("Local"),
|
||||
link: this.person.domain
|
||||
? {
|
||||
name: RouteName.INSTANCE,
|
||||
params: { domain: this.person.domain },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: this.$i18n.t("Uploaded media size"),
|
||||
@ -515,16 +543,3 @@ export default class AdminProfile extends Vue {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
table,
|
||||
section {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.actor-card {
|
||||
background: #fff;
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,73 +1,335 @@
|
||||
<template>
|
||||
<div v-if="user" class="section">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.ADMIN }">{{
|
||||
$t("Admin")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.USERS,
|
||||
}"
|
||||
>{{ $t("Users") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: user.id },
|
||||
}"
|
||||
>{{ user.email }}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<table v-if="metadata.length > 0" class="table is-fullwidth">
|
||||
<tbody>
|
||||
<tr v-for="{ key, value, link, elements, type } in metadata" :key="key">
|
||||
<td>{{ key }}</td>
|
||||
<td v-if="elements && elements.length > 0">
|
||||
<ul
|
||||
v-for="{ value, link: elementLink, active } in elements"
|
||||
:key="value"
|
||||
>
|
||||
<li>
|
||||
<router-link :to="elementLink">
|
||||
<span v-if="active">{{
|
||||
$t("{profile} (by default)", { profile: value })
|
||||
}}</span>
|
||||
<span v-else>{{ value }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td v-else-if="elements">
|
||||
{{ $t("None") }}
|
||||
</td>
|
||||
<td v-else-if="link">
|
||||
<router-link :to="link">
|
||||
{{ value }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td v-else-if="type == 'code'">
|
||||
<code>{{ value }}</code>
|
||||
</td>
|
||||
<td v-else>{{ value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="buttons">
|
||||
<b-button
|
||||
@click="deleteAccount"
|
||||
v-if="!user.disabled"
|
||||
type="is-primary"
|
||||
>{{ $t("Suspend") }}</b-button
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: $t('Admin') },
|
||||
{
|
||||
name: RouteName.USERS,
|
||||
text: $t('Users'),
|
||||
},
|
||||
{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: user.id },
|
||||
text: user.email,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg font-bold mb-3">{{ $t("Details") }}</h2>
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto sm:-mx-6">
|
||||
<div class="inline-block py-2 min-w-full sm:px-2">
|
||||
<div class="overflow-hidden shadow-md sm:rounded-lg">
|
||||
<table v-if="metadata.length > 0" class="min-w-full">
|
||||
<tbody>
|
||||
<tr
|
||||
class="odd:bg-white even:bg-gray-50 border-b"
|
||||
v-for="{ key, value, link, type } in metadata"
|
||||
:key="key"
|
||||
>
|
||||
<td class="py-4 px-2 whitespace-nowrap align-middle">
|
||||
{{ key }}
|
||||
</td>
|
||||
<td v-if="link" class="py-4 px-2 whitespace-nowrap">
|
||||
<router-link :to="link">
|
||||
{{ value }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'ip'"
|
||||
class="py-4 px-2 whitespace-nowrap"
|
||||
>
|
||||
<code>{{ value }}</code>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'role'"
|
||||
class="py-4 px-2 whitespace-nowrap"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
'bg-red-100 text-red-800':
|
||||
user.role == ICurrentUserRole.ADMINISTRATOR,
|
||||
'bg-yellow-100 text-yellow-800':
|
||||
user.role == ICurrentUserRole.MODERATOR,
|
||||
'bg-blue-100 text-blue-800':
|
||||
user.role == ICurrentUserRole.USER,
|
||||
}"
|
||||
class="text-sm font-medium mr-2 px-2.5 py-0.5 rounded"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-else class="py-4 px-2 align-middle">
|
||||
{{ value }}
|
||||
</td>
|
||||
<td
|
||||
v-if="type === 'email'"
|
||||
class="py-4 px-2 whitespace-nowrap flex flex flex-col items-start"
|
||||
>
|
||||
<b-button
|
||||
size="is-small"
|
||||
v-if="!user.disabled"
|
||||
@click="isEmailChangeModalActive = true"
|
||||
type="is-text"
|
||||
icon-left="pencil"
|
||||
>{{ $t("Change email") }}</b-button
|
||||
>
|
||||
<b-button
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.USERS,
|
||||
query: { emailFilter: `@${userEmailDomain}` },
|
||||
}"
|
||||
size="is-small"
|
||||
type="is-text"
|
||||
icon-left="magnify"
|
||||
>{{
|
||||
$t("Other users with the same email domain")
|
||||
}}</b-button
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'confirmed'"
|
||||
class="py-4 px-2 whitespace-nowrap flex items-center"
|
||||
>
|
||||
<b-button
|
||||
size="is-small"
|
||||
v-if="!user.confirmedAt || !user.disabled"
|
||||
@click="isConfirmationModalActive = true"
|
||||
type="is-text"
|
||||
icon-left="check"
|
||||
>{{ $t("Confirm user") }}</b-button
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'role'"
|
||||
class="py-4 px-2 whitespace-nowrap flex items-center"
|
||||
>
|
||||
<b-button
|
||||
size="is-small"
|
||||
v-if="!user.disabled"
|
||||
@click="isRoleChangeModalActive = true"
|
||||
type="is-text"
|
||||
icon-left="chevron-double-up"
|
||||
>{{ $t("Change role") }}</b-button
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-else-if="type === 'ip' && user.currentSignInIp"
|
||||
class="py-4 px-2 whitespace-nowrap flex items-center"
|
||||
>
|
||||
<b-button
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.USERS,
|
||||
query: { ipFilter: user.currentSignInIp },
|
||||
}"
|
||||
size="is-small"
|
||||
type="is-text"
|
||||
icon-left="web"
|
||||
>{{
|
||||
$t("Other users with the same IP address")
|
||||
}}</b-button
|
||||
>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
<empty-content v-else-if="!$apollo.loading" icon="account">
|
||||
{{ $t("This user was not found") }}
|
||||
@ -82,16 +344,16 @@
|
||||
</empty-content>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||
import { Route } from "vue-router";
|
||||
import { Component, Vue, Prop, Watch } from "vue-property-decorator";
|
||||
import { formatBytes } from "@/utils/datetime";
|
||||
import { ICurrentUserRole } from "@/types/enums";
|
||||
import { GET_USER, SUSPEND_USER } from "../../graphql/user";
|
||||
import { usernameWithDomain } from "../../types/actor/actor.model";
|
||||
import { IActor, usernameWithDomain } from "../../types/actor/actor.model";
|
||||
import RouteName from "../../router/name";
|
||||
import { IUser } from "../../types/current-user.model";
|
||||
import { IPerson } from "../../types/actor";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
import ActorCard from "../../components/Account/ActorCard.vue";
|
||||
import { ADMIN_UPDATE_USER, LANGUAGES_CODES } from "@/graphql/admin";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
@ -107,6 +369,17 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
return !this.id;
|
||||
},
|
||||
},
|
||||
languages: {
|
||||
query: LANGUAGES_CODES,
|
||||
variables() {
|
||||
return {
|
||||
codes: [this.languageCode],
|
||||
};
|
||||
},
|
||||
skip() {
|
||||
return !this.languageCode;
|
||||
},
|
||||
},
|
||||
},
|
||||
metaInfo() {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@ -118,6 +391,7 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
},
|
||||
components: {
|
||||
EmptyContent,
|
||||
ActorCard,
|
||||
},
|
||||
})
|
||||
export default class AdminUserProfile extends Vue {
|
||||
@ -125,24 +399,45 @@ export default class AdminUserProfile extends Vue {
|
||||
|
||||
user!: IUser;
|
||||
|
||||
languages!: Array<{ code: string; name: string }>;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
ICurrentUserRole = ICurrentUserRole;
|
||||
|
||||
isEmailChangeModalActive = false;
|
||||
|
||||
isRoleChangeModalActive = false;
|
||||
|
||||
isConfirmationModalActive = false;
|
||||
|
||||
newUser = {
|
||||
email: "",
|
||||
role: this?.user?.role,
|
||||
confirm: false,
|
||||
notify: true,
|
||||
};
|
||||
|
||||
get metadata(): Array<Record<string, unknown>> {
|
||||
if (!this.user) return [];
|
||||
return [
|
||||
{
|
||||
key: this.$i18n.t("Email"),
|
||||
value: this.user.email,
|
||||
type: "email",
|
||||
},
|
||||
{
|
||||
key: this.$i18n.t("Language"),
|
||||
value: this.user.locale,
|
||||
value: this.languages
|
||||
? this.languages[0].name
|
||||
: this.$i18n.t("Unknown"),
|
||||
},
|
||||
{
|
||||
key: this.$i18n.t("Role"),
|
||||
value: this.roleName(this.user.role),
|
||||
type: "role",
|
||||
},
|
||||
{
|
||||
key: this.$i18n.t("Login status"),
|
||||
@ -150,26 +445,13 @@ export default class AdminUserProfile extends Vue {
|
||||
? this.$i18n.t("Disabled")
|
||||
: this.$t("Activated"),
|
||||
},
|
||||
{
|
||||
key: this.$i18n.t("Profiles"),
|
||||
elements: this.user.actors.map((actor: IPerson) => {
|
||||
return {
|
||||
link: { name: RouteName.ADMIN_PROFILE, params: { id: actor.id } },
|
||||
value: actor.name
|
||||
? `${actor.name} (${actor.preferredUsername})`
|
||||
: actor.preferredUsername,
|
||||
active: this.user.defaultActor
|
||||
? actor.id === this.user.defaultActor.id
|
||||
: false,
|
||||
};
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: this.$i18n.t("Confirmed"),
|
||||
value:
|
||||
this.$options.filters && this.user.confirmedAt
|
||||
? this.$options.filters.formatDateTimeString(this.user.confirmedAt)
|
||||
: this.$i18n.t("Not confirmed"),
|
||||
type: "confirmed",
|
||||
},
|
||||
{
|
||||
key: this.$i18n.t("Last sign-in"),
|
||||
@ -183,15 +465,19 @@ export default class AdminUserProfile extends Vue {
|
||||
{
|
||||
key: this.$i18n.t("Last IP adress"),
|
||||
value: this.user.currentSignInIp || this.$t("Unknown"),
|
||||
type: "code",
|
||||
type: this.user.currentSignInIp ? "ip" : undefined,
|
||||
},
|
||||
{
|
||||
key: this.$i18n.t("Participations"),
|
||||
key: this.$i18n.t("Total number of participations"),
|
||||
value: this.user.participations.total,
|
||||
},
|
||||
{
|
||||
key: this.$i18n.t("Uploaded media size"),
|
||||
value: formatBytes(this.user.mediaSize),
|
||||
key: this.$i18n.t("Uploaded media total size"),
|
||||
value: formatBytes(
|
||||
this.user.mediaSize,
|
||||
2,
|
||||
this.$i18n.t("0 Bytes") as string
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -208,20 +494,89 @@ export default class AdminUserProfile extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(): Promise<Route> {
|
||||
await this.$apollo.mutate<{ suspendProfile: { id: string } }>({
|
||||
mutation: SUSPEND_USER,
|
||||
variables: {
|
||||
userId: this.id,
|
||||
async suspendAccount(): Promise<void> {
|
||||
this.$buefy.dialog.confirm({
|
||||
title: this.$t("Suspend the account?") as string,
|
||||
message: this.$t(
|
||||
"Do you really want to suspend this account? All of the user's profiles will be deleted."
|
||||
) as string,
|
||||
confirmText: this.$t("Suspend the account") as string,
|
||||
cancelText: this.$t("Cancel") as string,
|
||||
type: "is-danger",
|
||||
onConfirm: async () => {
|
||||
await this.$apollo.mutate<{ suspendProfile: { id: string } }>({
|
||||
mutation: SUSPEND_USER,
|
||||
variables: {
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
table {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,19 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.ADMIN }">{{
|
||||
$t("Admin")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.ADMIN_DASHBOARD }">{{
|
||||
$t("Dashboard")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: $t('Admin') },
|
||||
{ text: $t('Dashboard') },
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h1 class="title">{{ $t("Administration") }}</h1>
|
||||
<div class="tile is-ancestor" v-if="dashboard">
|
||||
|
@ -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>
|
@ -1,19 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MODERATION }">{{
|
||||
$t("Moderation")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.PROFILES }">{{
|
||||
$t("Groups")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MODERATION, text: $t('Moderation') },
|
||||
{
|
||||
name: RouteName.ADMIN_GROUPS,
|
||||
text: $t('Groups'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div class="buttons" v-if="showCreateGroupsButton">
|
||||
<router-link
|
||||
class="button is-primary"
|
||||
|
246
js/src/views/Admin/Instance.vue
Normal file
246
js/src/views/Admin/Instance.vue
Normal 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>
|
293
js/src/views/Admin/Instances.vue
Normal file
293
js/src/views/Admin/Instances.vue
Normal 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>
|
@ -1,19 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MODERATION }">{{
|
||||
$t("Moderation")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.PROFILES }">{{
|
||||
$t("Profiles")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MODERATION, text: $t('Moderation') },
|
||||
{
|
||||
name: RouteName.PROFILES,
|
||||
text: $t('Profiles'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div v-if="persons">
|
||||
<b-switch v-model="local">{{ $t("Local") }}</b-switch>
|
||||
<b-switch v-model="suspended">{{ $t("Suspended") }}</b-switch>
|
||||
|
@ -1,19 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.ADMIN }">{{
|
||||
$t("Admin")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.ADMIN_SETTINGS }">{{
|
||||
$t("Instance settings")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.ADMIN, text: $t('Admin') },
|
||||
{ text: $t('Instance settings') },
|
||||
]"
|
||||
/>
|
||||
|
||||
<section v-if="settingsToWrite">
|
||||
<form @submit.prevent="updateSettings">
|
||||
<b-field :label="$t('Instance Name')" label-for="instance-name">
|
||||
|
@ -1,27 +1,36 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MODERATION }">{{
|
||||
$t("Moderation")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.USERS }">{{
|
||||
$t("Users")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MODERATION, text: $t('Moderation') },
|
||||
{
|
||||
name: RouteName.USERS,
|
||||
text: $t('Users'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div v-if="users">
|
||||
<form @submit.prevent="activateFilters">
|
||||
<b-field class="mb-5" grouped group-multiline>
|
||||
<b-field :label="$t('Email')" expanded>
|
||||
<b-input trap-focus icon="email" v-model="emailFilterFieldValue" />
|
||||
</b-field>
|
||||
<b-field :label="$t('IP Address')" expanded>
|
||||
<b-input icon="web" v-model="ipFilterFieldValue" />
|
||||
</b-field>
|
||||
<p class="control self-end mb-0">
|
||||
<b-button type="is-primary" native-type="submit">{{
|
||||
$t("Filter")
|
||||
}}</b-button>
|
||||
</p>
|
||||
</b-field>
|
||||
</form>
|
||||
<b-table
|
||||
:data="users.elements"
|
||||
:loading="$apollo.queries.users.loading"
|
||||
paginated
|
||||
backend-pagination
|
||||
backend-filtering
|
||||
detailed
|
||||
:debounce-search="500"
|
||||
:current-page.sync="page"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
@ -30,25 +39,14 @@
|
||||
:show-detail-icon="true"
|
||||
:total="users.total"
|
||||
:per-page="USERS_PER_PAGE"
|
||||
:has-detailed-visible="(row) => row.actors.length > 0"
|
||||
@page-change="onPageChange"
|
||||
@filters-change="onFiltersChange"
|
||||
>
|
||||
<b-table-column field="id" width="40" numeric v-slot="props">
|
||||
{{ props.row.id }}
|
||||
</b-table-column>
|
||||
<b-table-column field="email" :label="$t('Email')" searchable>
|
||||
<template #searchable="props">
|
||||
<b-input
|
||||
v-model="props.filters.email"
|
||||
:aria-label="$t('Filter')"
|
||||
:placeholder="$t('Filter')"
|
||||
icon="magnify"
|
||||
/>
|
||||
</template>
|
||||
<b-table-column field="email" :label="$t('Email')">
|
||||
<template v-slot:default="props">
|
||||
<router-link
|
||||
class="user-profile"
|
||||
:to="{
|
||||
name: RouteName.ADMIN_USER_PROFILE,
|
||||
params: { id: props.row.id },
|
||||
@ -61,13 +59,16 @@
|
||||
</b-table-column>
|
||||
<b-table-column
|
||||
field="confirmedAt"
|
||||
:label="$t('Confirmed at')"
|
||||
:label="$t('Last seen on')"
|
||||
:centered="true"
|
||||
v-slot="props"
|
||||
>
|
||||
<template v-if="props.row.confirmedAt">
|
||||
{{ props.row.confirmedAt | formatDateTimeString }}
|
||||
<template v-if="props.row.currentSignInAt">
|
||||
<time :datetime="props.row.currentSignInAt">
|
||||
{{ props.row.currentSignInAt | formatDateTimeString }}
|
||||
</time>
|
||||
</template>
|
||||
<template v-else-if="props.row.confirmedAt"> - </template>
|
||||
<template v-else>
|
||||
{{ $t("Not confirmed") }}
|
||||
</template>
|
||||
@ -80,30 +81,19 @@
|
||||
>
|
||||
{{ getLanguageNameForCode(props.row.locale) }}
|
||||
</b-table-column>
|
||||
|
||||
<template #detail="props">
|
||||
<router-link
|
||||
class="profile"
|
||||
v-for="actor in props.row.actors"
|
||||
:key="actor.id"
|
||||
:to="{ name: RouteName.ADMIN_PROFILE, params: { id: actor.id } }"
|
||||
<template #empty>
|
||||
<empty-content
|
||||
v-if="!$apollo.loading && emailFilter"
|
||||
:inline="true"
|
||||
icon="account"
|
||||
>
|
||||
<article class="media">
|
||||
<figure class="media-left">
|
||||
<p class="image is-32x32" v-if="actor.avatar">
|
||||
<img :src="actor.avatar.url" />
|
||||
</p>
|
||||
<b-icon v-else size="is-medium" icon="account-circle" />
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<strong v-if="actor.name">{{ actor.name }}</strong>
|
||||
<small>@{{ actor.preferredUsername }}</small>
|
||||
<p>{{ actor.summary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</router-link>
|
||||
{{ $t("No user matches the filters") }}
|
||||
<template #desc>
|
||||
<b-button type="is-primary" @click="resetFilters">
|
||||
{{ $t("Reset filters") }}
|
||||
</b-button>
|
||||
</template>
|
||||
</empty-content>
|
||||
</template>
|
||||
</b-table>
|
||||
</div>
|
||||
@ -117,6 +107,7 @@ import VueRouter from "vue-router";
|
||||
import { LANGUAGES_CODES } from "@/graphql/admin";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
||||
|
||||
const USERS_PER_PAGE = 10;
|
||||
@ -128,7 +119,8 @@ const USERS_PER_PAGE = 10;
|
||||
fetchPolicy: "cache-and-network",
|
||||
variables() {
|
||||
return {
|
||||
email: this.email,
|
||||
email: this.emailFilter,
|
||||
currentSignInIp: this.ipFilter,
|
||||
page: this.page,
|
||||
limit: USERS_PER_PAGE,
|
||||
};
|
||||
@ -151,6 +143,9 @@ const USERS_PER_PAGE = 10;
|
||||
title: this.$t("Users") as string,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
EmptyContent,
|
||||
},
|
||||
})
|
||||
export default class Users extends Vue {
|
||||
USERS_PER_PAGE = USERS_PER_PAGE;
|
||||
@ -160,6 +155,9 @@ export default class Users extends Vue {
|
||||
users!: Paginate<IUser>;
|
||||
languages!: Array<{ code: string; name: string }>;
|
||||
|
||||
emailFilterFieldValue = this.emailFilter;
|
||||
ipFilterFieldValue = this.ipFilter;
|
||||
|
||||
get page(): number {
|
||||
return parseInt((this.$route.query.page as string) || "1", 10);
|
||||
}
|
||||
@ -168,12 +166,20 @@ export default class Users extends Vue {
|
||||
this.pushRouter({ page: page.toString() });
|
||||
}
|
||||
|
||||
get email(): string {
|
||||
return (this.$route.query.email as string) || "";
|
||||
get emailFilter(): string {
|
||||
return (this.$route.query.emailFilter as string) || "";
|
||||
}
|
||||
|
||||
set email(email: string) {
|
||||
this.pushRouter({ email });
|
||||
set emailFilter(emailFilter: string) {
|
||||
this.pushRouter({ emailFilter });
|
||||
}
|
||||
|
||||
get ipFilter(): string {
|
||||
return (this.$route.query.ipFilter as string) || "";
|
||||
}
|
||||
|
||||
set ipFilter(ipFilter: string) {
|
||||
this.pushRouter({ ipFilter });
|
||||
}
|
||||
|
||||
get languagesCodes(): string[] {
|
||||
@ -192,15 +198,23 @@ export default class Users extends Vue {
|
||||
this.page = page;
|
||||
await this.$apollo.queries.users.fetchMore({
|
||||
variables: {
|
||||
email: this.email,
|
||||
email: this.emailFilter,
|
||||
currentSignInIp: this.ipFilter,
|
||||
page: this.page,
|
||||
limit: USERS_PER_PAGE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onFiltersChange({ email }: { email: string }): void {
|
||||
this.email = email;
|
||||
activateFilters(): void {
|
||||
this.emailFilter = this.emailFilterFieldValue;
|
||||
this.ipFilter = this.ipFilterFieldValue;
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.emailFilterFieldValue = "";
|
||||
this.ipFilterFieldValue = "";
|
||||
this.activateFilters();
|
||||
}
|
||||
|
||||
private async pushRouter(args: Record<string, string>): Promise<void> {
|
||||
|
@ -1,42 +1,29 @@
|
||||
<template>
|
||||
<section class="section container">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul v-if="group">
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_GROUPS }">{{
|
||||
$t("My groups")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.DISCUSSION_LIST,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ $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>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MY_GROUPS,
|
||||
text: $t('My groups'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.DISCUSSION_LIST,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Discussions'),
|
||||
},
|
||||
{
|
||||
name: RouteName.CREATE_DISCUSSION,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Create'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<h1 class="title">{{ $t("Create a discussion") }}</h1>
|
||||
|
||||
<form @submit.prevent="createDiscussion">
|
||||
@ -67,7 +54,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { IGroup, IPerson, usernameWithDomain } from "@/types/actor";
|
||||
import {
|
||||
displayName,
|
||||
IGroup,
|
||||
IPerson,
|
||||
usernameWithDomain,
|
||||
} from "@/types/actor";
|
||||
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
||||
import { FETCH_GROUP } from "@/graphql/group";
|
||||
import { CREATE_DISCUSSION } from "@/graphql/discussion";
|
||||
@ -113,6 +105,8 @@ export default class CreateDiscussion extends Vue {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
async createDiscussion(): Promise<void> {
|
||||
this.errors = { title: "" };
|
||||
try {
|
||||
|
@ -1,46 +1,29 @@
|
||||
<template>
|
||||
<div class="container section" v-if="discussion">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_GROUPS }">{{
|
||||
$t("My groups")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
v-if="discussion.actor"
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(discussion.actor),
|
||||
},
|
||||
}"
|
||||
>{{ discussion.actor.name }}</router-link
|
||||
>
|
||||
<b-skeleton v-else-if="$apollo.loading" animated />
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
v-if="discussion.actor"
|
||||
:to="{
|
||||
name: RouteName.DISCUSSION_LIST,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(discussion.actor),
|
||||
},
|
||||
}"
|
||||
>{{ $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>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MY_GROUPS,
|
||||
text: $t('My groups'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.DISCUSSION_LIST,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Discussions'),
|
||||
},
|
||||
{
|
||||
name: RouteName.DISCUSSION,
|
||||
params: { id: discussion.id },
|
||||
text: discussion.title,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<b-message v-if="error" type="is-danger">
|
||||
{{ error }}
|
||||
</b-message>
|
||||
@ -148,7 +131,7 @@ import {
|
||||
} from "@/graphql/discussion";
|
||||
import { IDiscussion } from "@/types/discussions";
|
||||
import { Discussion as DiscussionModel } from "@/types/discussions";
|
||||
import { usernameWithDomain } from "@/types/actor";
|
||||
import { displayName, usernameWithDomain } from "@/types/actor";
|
||||
import DiscussionComment from "@/components/Discussion/DiscussionComment.vue";
|
||||
import { GraphQLError } from "graphql";
|
||||
import { DELETE_COMMENT, UPDATE_COMMENT } from "@/graphql/comment";
|
||||
@ -250,6 +233,7 @@ export default class Discussion extends mixins(GroupMixin) {
|
||||
RouteName = RouteName;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
displayName = displayName;
|
||||
error: string | null = null;
|
||||
|
||||
async reply(): Promise<void> {
|
||||
|
@ -1,32 +1,23 @@
|
||||
<template>
|
||||
<div class="container section" v-if="group">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_GROUPS }">{{
|
||||
$t("My groups")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.DISCUSSION_LIST,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ $t("Discussions") }}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MY_GROUPS,
|
||||
text: $t('My groups'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.DISCUSSION_LIST,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Discussions'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section v-if="isCurrentActorAGroupMember">
|
||||
<p>
|
||||
{{
|
||||
@ -82,7 +73,13 @@
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { FETCH_GROUP } from "@/graphql/group";
|
||||
import { IActor, IGroup, IPerson, usernameWithDomain } from "@/types/actor";
|
||||
import {
|
||||
displayName,
|
||||
IActor,
|
||||
IGroup,
|
||||
IPerson,
|
||||
usernameWithDomain,
|
||||
} from "@/types/actor";
|
||||
import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
|
||||
import RouteName from "../../router/name";
|
||||
import { MemberRole } from "@/types/enums";
|
||||
@ -166,6 +163,7 @@ export default class DiscussionsList extends Vue {
|
||||
RouteName = RouteName;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
displayName = displayName;
|
||||
|
||||
DISCUSSIONS_PER_PAGE = DISCUSSIONS_PER_PAGE;
|
||||
|
||||
|
@ -1,27 +1,19 @@
|
||||
<template>
|
||||
<div class="container section" v-if="group">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.TODO_LISTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ $t("Events") }}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.EVENTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Events'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h1 class="title" v-if="group">
|
||||
{{
|
||||
@ -89,7 +81,7 @@ import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import GroupMixin from "@/mixins/group";
|
||||
import { IMember } from "@/types/actor/member.model";
|
||||
import { FETCH_GROUP_EVENTS } from "@/graphql/event";
|
||||
import { usernameWithDomain } from "../../types/actor";
|
||||
import { displayName, usernameWithDomain } from "../../types/actor";
|
||||
|
||||
const EVENTS_PAGE_LIMIT = 10;
|
||||
|
||||
@ -143,6 +135,8 @@ export default class GroupEvents extends mixins(GroupMixin) {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
EVENTS_PAGE_LIMIT = EVENTS_PAGE_LIMIT;
|
||||
|
@ -1,32 +1,20 @@
|
||||
<template>
|
||||
<section class="section container" v-if="event">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_EVENTS }">{{
|
||||
$t("My events")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: event.uuid },
|
||||
}"
|
||||
>{{ 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>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{ name: RouteName.MY_EVENTS, text: $t('My events') },
|
||||
{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: event.uuid },
|
||||
text: event.title,
|
||||
},
|
||||
{
|
||||
name: RouteName.PARTICIPANTS,
|
||||
params: { uuid: event.uuid },
|
||||
text: $t('Participants'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<h1 class="title">{{ $t("Participants") }}</h1>
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
|
@ -1,27 +1,17 @@
|
||||
<template>
|
||||
<div class="container is-widescreen">
|
||||
<div class="header">
|
||||
<nav class="breadcrumb" :aria-label="$t('Breadcrumbs')">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_GROUPS }">{{
|
||||
$t("My groups")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
aria-current-value="location"
|
||||
v-if="group && group.preferredUsername"
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name }}</router-link
|
||||
>
|
||||
<b-skeleton v-else :animated="true"></b-skeleton>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{ name: RouteName.MY_GROUPS, text: $t('My groups') },
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<b-loading :active.sync="$apollo.loading"></b-loading>
|
||||
<header class="block-container presentation" v-if="group">
|
||||
<div class="banner-container">
|
||||
@ -776,6 +766,8 @@ export default class Group extends mixins(GroupMixin) {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
PostVisibility = PostVisibility;
|
||||
|
||||
Openness = Openness;
|
||||
|
@ -1,36 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul v-if="group">
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ $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>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Settings'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Followers'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<b-loading :active="$apollo.loading" />
|
||||
<section
|
||||
class="container section"
|
||||
@ -138,7 +127,7 @@ import GroupMixin from "@/mixins/group";
|
||||
import { mixins } from "vue-class-component";
|
||||
import { GROUP_FOLLOWERS, UPDATE_FOLLOWER } from "@/graphql/followers";
|
||||
import RouteName from "../../router/name";
|
||||
import { usernameWithDomain } from "../../types/actor";
|
||||
import { displayName, usernameWithDomain } from "../../types/actor";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import { IFollower } from "@/types/actor/follower.model";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
@ -181,6 +170,8 @@ export default class GroupFollowers extends mixins(GroupMixin) {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
followers!: Paginate<IFollower>;
|
||||
|
||||
mounted(): void {
|
||||
|
@ -1,36 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul v-if="group">
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ $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>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Settings'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_MEMBERS_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Members'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<b-loading :active="$apollo.loading" />
|
||||
<section
|
||||
class="container section"
|
||||
@ -312,6 +301,8 @@ export default class GroupMembers extends mixins(GroupMixin) {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
mounted(): void {
|
||||
const roleQuery = this.$route.query.role as string;
|
||||
if (Object.values(MemberRole).includes(roleQuery as MemberRole)) {
|
||||
|
@ -1,37 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
v-if="group"
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name || usernameWithDomain(group) }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ $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>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Settings'),
|
||||
},
|
||||
{
|
||||
name: RouteName.GROUP_PUBLIC_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Group settings'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<b-loading :active="$apollo.loading" />
|
||||
<section
|
||||
class="container section"
|
||||
@ -197,7 +185,12 @@ import { mixins } from "vue-class-component";
|
||||
import GroupMixin from "@/mixins/group";
|
||||
import { GroupVisibility, Openness } from "@/types/enums";
|
||||
import { UPDATE_GROUP } from "../../graphql/group";
|
||||
import { Group, IGroup, usernameWithDomain } from "../../types/actor";
|
||||
import {
|
||||
Group,
|
||||
IGroup,
|
||||
usernameWithDomain,
|
||||
displayName,
|
||||
} from "../../types/actor";
|
||||
import { Address, IAddress } from "../../types/address.model";
|
||||
import { CONFIG } from "@/graphql/config";
|
||||
import { IConfig } from "@/types/config.model";
|
||||
@ -234,6 +227,8 @@ export default class GroupSettings extends mixins(GroupMixin) {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
GroupVisibility = GroupVisibility;
|
||||
|
||||
Openness = Openness;
|
||||
|
@ -1,27 +1,21 @@
|
||||
<template>
|
||||
<div class="container section">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul v-if="group">
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.TIMELINE,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ $t("Activity") }}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
v-if="group"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.TIMELINE,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Activity'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<section class="timeline">
|
||||
<b-field>
|
||||
<b-radio-button v-model="activityType" :native-value="undefined">
|
||||
@ -160,7 +154,7 @@
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { GROUP_TIMELINE } from "@/graphql/group";
|
||||
import { IGroup, usernameWithDomain } from "@/types/actor";
|
||||
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
|
||||
import { ActivityType } from "@/types/enums";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
@ -234,6 +228,8 @@ export default class Timeline extends Vue {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
ActivityType = ActivityType;
|
||||
|
||||
ActivityAuthorFilter = ActivityAuthorFilter;
|
||||
|
@ -1,19 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MODERATION }">{{
|
||||
$t("Moderation")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.REPORT_LOGS }">{{
|
||||
$t("Moderation log")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MODERATION,
|
||||
text: $t('Moderation'),
|
||||
},
|
||||
{
|
||||
name: RouteName.REPORT_LOGS,
|
||||
text: $t('Moderation log'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section v-if="actionLogs.total > 0 && actionLogs.elements.length > 0">
|
||||
<ul>
|
||||
<li v-for="log in actionLogs.elements" :key="log.id">
|
||||
|
@ -1,27 +1,23 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="report">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MODERATION }">{{
|
||||
$t("Moderation")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.REPORTS }">{{
|
||||
$t("Reports")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{ name: RouteName.REPORT, params: { id: report.id } }"
|
||||
>{{
|
||||
$t("Report #{reportNumber}", { reportNumber: report.id })
|
||||
}}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
v-if="report"
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MODERATION,
|
||||
text: $t('Moderation'),
|
||||
},
|
||||
{
|
||||
name: RouteName.REPORTS,
|
||||
text: $t('Reports'),
|
||||
},
|
||||
{
|
||||
name: RouteName.REPORT,
|
||||
params: { id: report.id },
|
||||
text: $t('Report #{reportNumber}', { reportNumber: report.id }),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<b-message
|
||||
title="Error"
|
||||
|
@ -1,37 +1,49 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MODERATION }">{{
|
||||
$t("Moderation")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.REPORTS }">{{
|
||||
$t("Reports")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.MODERATION,
|
||||
text: $t('Moderation'),
|
||||
},
|
||||
{
|
||||
name: RouteName.REPORTS,
|
||||
text: $t('Reports'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<b-field>
|
||||
<b-radio-button
|
||||
v-model="status"
|
||||
:native-value="ReportStatusEnum.OPEN"
|
||||
>{{ $t("Open") }}</b-radio-button
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<b-field :label="$t('Report status')">
|
||||
<b-radio-button
|
||||
v-model="status"
|
||||
: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
|
||||
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-input
|
||||
id="domain-filter"
|
||||
:placeholder="$t('mobilizon-instance.tld')"
|
||||
:value="filterDomain"
|
||||
@input="debouncedUpdateDomainFilter"
|
||||
/>
|
||||
</b-field>
|
||||
</div>
|
||||
<ul v-if="reports.elements.length > 0">
|
||||
<li v-for="report in reports.elements" :key="report.id">
|
||||
<router-link
|
||||
@ -88,6 +100,7 @@ import { ReportStatusEnum } from "@/types/enums";
|
||||
import RouteName from "../../router/name";
|
||||
import VueRouter from "vue-router";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import debounce from "lodash/debounce";
|
||||
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
||||
|
||||
const REPORT_PAGE_LIMIT = 10;
|
||||
@ -106,6 +119,7 @@ const REPORT_PAGE_LIMIT = 10;
|
||||
page: this.page,
|
||||
status: this.status,
|
||||
limit: REPORT_PAGE_LIMIT,
|
||||
domain: this.filterDomain,
|
||||
};
|
||||
},
|
||||
pollInterval: 120000, // 2 minutes
|
||||
@ -128,18 +142,28 @@ export default class ReportList extends Vue {
|
||||
|
||||
REPORT_PAGE_LIMIT = REPORT_PAGE_LIMIT;
|
||||
|
||||
data(): Record<string, unknown> {
|
||||
return {
|
||||
debouncedUpdateDomainFilter: debounce(this.updateDomainFilter, 500),
|
||||
};
|
||||
}
|
||||
|
||||
async updateDomainFilter(domain: string) {
|
||||
this.filterDomain = domain;
|
||||
}
|
||||
|
||||
get page(): number {
|
||||
return parseInt((this.$route.query.page as string) || "1", 10);
|
||||
}
|
||||
|
||||
set page(page: number) {
|
||||
this.pushRouter(RouteName.REPORTS, {
|
||||
this.pushRouter({
|
||||
page: page.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
get status(): ReportStatusEnum {
|
||||
const filter = this.$route.params.filter?.toUpperCase();
|
||||
const filter = (this.$route.query.status || "") as string;
|
||||
if (filter in ReportStatusEnum) {
|
||||
return filter as ReportStatusEnum;
|
||||
}
|
||||
@ -147,19 +171,21 @@ export default class ReportList extends Vue {
|
||||
}
|
||||
|
||||
set status(status: ReportStatusEnum) {
|
||||
this.$router.push({
|
||||
name: RouteName.REPORTS,
|
||||
params: { filter: status.toLowerCase() },
|
||||
});
|
||||
this.pushRouter({ status });
|
||||
}
|
||||
|
||||
protected async pushRouter(
|
||||
routeName: string,
|
||||
args: Record<string, string>
|
||||
): Promise<void> {
|
||||
get filterDomain(): string {
|
||||
return (this.$route.query.domain as string) || "";
|
||||
}
|
||||
|
||||
set filterDomain(domain: string) {
|
||||
this.pushRouter({ domain });
|
||||
}
|
||||
|
||||
protected async pushRouter(args: Record<string, string>): Promise<void> {
|
||||
try {
|
||||
await this.$router.push({
|
||||
name: routeName,
|
||||
name: RouteName.REPORTS,
|
||||
params: this.$route.params,
|
||||
query: { ...this.$route.query, ...args },
|
||||
});
|
||||
|
@ -2,43 +2,7 @@
|
||||
<div>
|
||||
<form @submit.prevent="publish(false)" v-if="isCurrentActorAGroupModerator">
|
||||
<div class="container section">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="actualGroup">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
v-if="actualGroup"
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(actualGroup),
|
||||
},
|
||||
}"
|
||||
>{{
|
||||
actualGroup.name || actualGroup.preferredUsername
|
||||
}}</router-link
|
||||
>
|
||||
<b-skeleton v-else :animated="true"></b-skeleton>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
v-if="actualGroup"
|
||||
:to="{
|
||||
name: RouteName.POSTS,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(actualGroup),
|
||||
},
|
||||
}"
|
||||
>{{ $t("Posts") }}</router-link
|
||||
>
|
||||
<b-skeleton v-else :animated="true"></b-skeleton>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<span v-if="preferredUsername">{{ $t("New post") }}</span>
|
||||
<span v-else-if="slug">{{ $t("Edit post") }}</span>
|
||||
<b-skeleton v-else :animated="true"></b-skeleton>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav v-if="actualGroup" :links="breadcrumbLinks" />
|
||||
<h1 class="title" v-if="isUpdate === true">
|
||||
{{ $t("Edit post") }}
|
||||
</h1>
|
||||
@ -174,7 +138,7 @@ import { CREATE_POST, UPDATE_POST } from "../../graphql/post";
|
||||
|
||||
import { IPost } from "../../types/post.model";
|
||||
import Editor from "../../components/Editor.vue";
|
||||
import { IActor, usernameWithDomain } from "../../types/actor";
|
||||
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
|
||||
import TagInput from "../../components/Event/TagInput.vue";
|
||||
import RouteName from "../../router/name";
|
||||
import Subtitle from "../../components/Utils/Subtitle.vue";
|
||||
@ -366,6 +330,39 @@ export default class EditPost extends mixins(GroupMixin, PostMixin) {
|
||||
}
|
||||
return this.group;
|
||||
}
|
||||
|
||||
get breadcrumbLinks() {
|
||||
const links = [
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(this.actualGroup),
|
||||
},
|
||||
text: displayName(this.actualGroup),
|
||||
},
|
||||
{
|
||||
name: RouteName.POSTS,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(this.actualGroup),
|
||||
},
|
||||
text: this.$t("Posts"),
|
||||
},
|
||||
];
|
||||
if (this.preferredUsername) {
|
||||
links.push({
|
||||
text: this.$t("New post") as string,
|
||||
name: RouteName.POST_EDIT,
|
||||
params: { preferredUsername: usernameWithDomain(this.actualGroup) },
|
||||
});
|
||||
} else {
|
||||
links.push({
|
||||
text: this.$t("Edit post") as string,
|
||||
name: RouteName.POST_EDIT,
|
||||
params: { preferredUsername: usernameWithDomain(this.actualGroup) },
|
||||
});
|
||||
}
|
||||
return links;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
@ -1,31 +1,19 @@
|
||||
<template>
|
||||
<div class="container section" v-if="group">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
v-if="group"
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name || group.preferredUsername }}</router-link
|
||||
>
|
||||
<b-skeleton v-else :animated="true"></b-skeleton>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
v-if="group"
|
||||
:to="{
|
||||
name: RouteName.POSTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ $t("Posts") }}</router-link
|
||||
>
|
||||
<b-skeleton v-else :animated="true"></b-skeleton>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.POSTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Posts'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<div class="intro">
|
||||
<p v-if="isCurrentActorMember">
|
||||
@ -84,7 +72,7 @@ import { IMember } from "@/types/actor/member.model";
|
||||
import { FETCH_GROUP_POSTS } from "../../graphql/post";
|
||||
import { Paginate } from "../../types/paginate";
|
||||
import { IPost } from "../../types/post.model";
|
||||
import { usernameWithDomain } from "../../types/actor";
|
||||
import { usernameWithDomain, displayName } from "../../types/actor";
|
||||
import RouteName from "../../router/name";
|
||||
import MultiPostListItem from "../../components/Post/MultiPostListItem.vue";
|
||||
|
||||
@ -148,6 +136,8 @@ export default class PostList extends mixins(GroupMixin) {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
POSTS_PAGE_LIMIT = POSTS_PAGE_LIMIT;
|
||||
|
||||
get isCurrentActorMember(): boolean {
|
||||
|
@ -1,80 +1,38 @@
|
||||
<template>
|
||||
<div class="container section" v-if="resource">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(resource.actor) },
|
||||
}"
|
||||
>{{ resource.actor.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.RESOURCE_FOLDER_ROOT,
|
||||
params: { preferredUsername: usernameWithDomain(resource.actor) },
|
||||
}"
|
||||
>{{ $t("Resources") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
:class="{
|
||||
'is-active':
|
||||
index + 1 === ResourceMixin.resourcePathArray(resource).length,
|
||||
}"
|
||||
v-for="(pathFragment, index) in filteredPath"
|
||||
:key="pathFragment"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.RESOURCE_FOLDER,
|
||||
params: {
|
||||
path: ResourceMixin.resourcePathArray(resource).slice(
|
||||
0,
|
||||
index + 1
|
||||
),
|
||||
preferredUsername: usernameWithDomain(resource.actor),
|
||||
},
|
||||
}"
|
||||
>{{ pathFragment }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<b-dropdown aria-role="list">
|
||||
<b-button class="button is-primary" slot="trigger">+</b-button>
|
||||
<breadcrumbs-nav :links="breadcrumbLinks">
|
||||
<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-icon icon="folder" />
|
||||
{{ $t("New folder") }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
aria-role="listitem"
|
||||
@click="createLinkResourceModal = true"
|
||||
>
|
||||
<b-icon icon="link" />
|
||||
{{ $t("New link") }}
|
||||
</b-dropdown-item>
|
||||
<hr
|
||||
role="presentation"
|
||||
class="dropdown-divider"
|
||||
v-if="resourceProviders.length"
|
||||
/>
|
||||
<b-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-for="resourceProvider in resourceProviders"
|
||||
:key="resourceProvider.software"
|
||||
@click="createResourceFromProvider(resourceProvider)"
|
||||
>
|
||||
<b-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" />
|
||||
{{ createSentenceForType(resourceProvider.software) }}
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<b-dropdown-item aria-role="listitem" @click="createFolderModal">
|
||||
<b-icon icon="folder" />
|
||||
{{ $t("New folder") }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
aria-role="listitem"
|
||||
@click="createLinkResourceModal = true"
|
||||
>
|
||||
<b-icon icon="link" />
|
||||
{{ $t("New link") }}
|
||||
</b-dropdown-item>
|
||||
<hr
|
||||
role="presentation"
|
||||
class="dropdown-divider"
|
||||
v-if="resourceProviders.length"
|
||||
/>
|
||||
<b-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-for="resourceProvider in resourceProviders"
|
||||
:key="resourceProvider.software"
|
||||
@click="createResourceFromProvider(resourceProvider)"
|
||||
>
|
||||
<b-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" />
|
||||
{{ createSentenceForType(resourceProvider.software) }}
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</li>
|
||||
</breadcrumbs-nav>
|
||||
<section>
|
||||
<p v-if="resource.path === '/'" class="module-description">
|
||||
{{
|
||||
@ -276,7 +234,7 @@ import ResourceItem from "@/components/Resource/ResourceItem.vue";
|
||||
import FolderItem from "@/components/Resource/FolderItem.vue";
|
||||
import Draggable from "vuedraggable";
|
||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
||||
import { IActor, usernameWithDomain } from "../../types/actor";
|
||||
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
|
||||
import RouteName from "../../router/name";
|
||||
import {
|
||||
IResource,
|
||||
@ -764,6 +722,40 @@ export default class Resources extends Mixins(ResourceMixin) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get breadcrumbLinks() {
|
||||
if (!this.resource?.actor) return [];
|
||||
const resourceActor = this.resource.actor;
|
||||
const links = [
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(this.resource.actor) },
|
||||
text: displayName(this.resource.actor),
|
||||
},
|
||||
{
|
||||
name: RouteName.RESOURCE_FOLDER_ROOT,
|
||||
params: { preferredUsername: usernameWithDomain(this.resource.actor) },
|
||||
text: this.$t("Resources") as string,
|
||||
},
|
||||
];
|
||||
|
||||
links.push(
|
||||
...this.filteredPath.map((pathFragment, index) => {
|
||||
return {
|
||||
name: RouteName.RESOURCE_FOLDER,
|
||||
params: {
|
||||
path: ResourceMixin.resourcePathArray(this.resource).slice(
|
||||
0,
|
||||
index + 1
|
||||
) as unknown as string,
|
||||
preferredUsername: usernameWithDomain(resourceActor),
|
||||
},
|
||||
text: pathFragment,
|
||||
};
|
||||
})
|
||||
);
|
||||
return links;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
@ -1,19 +1,17 @@
|
||||
<template>
|
||||
<div v-if="loggedUser">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.ACCOUNT_SETTINGS }">{{
|
||||
$t("Account")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.ACCOUNT_SETTINGS_GENERAL }">{{
|
||||
$t("General")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS,
|
||||
text: $t('Account'),
|
||||
},
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
|
||||
text: $t('General'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<div class="setting-title">
|
||||
<h2>{{ $t("Email") }}</h2>
|
||||
|
@ -1,19 +1,17 @@
|
||||
<template>
|
||||
<div v-if="loggedUser">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.ACCOUNT_SETTINGS }">{{
|
||||
$t("Account")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.NOTIFICATIONS }">{{
|
||||
$t("Notifications")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS,
|
||||
text: $t('Account'),
|
||||
},
|
||||
{
|
||||
name: RouteName.NOTIFICATIONS,
|
||||
text: $t('Notifications'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<div class="setting-title">
|
||||
<h2>{{ $t("Browser notifications") }}</h2>
|
||||
|
@ -1,19 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.ACCOUNT_SETTINGS }">{{
|
||||
$t("Account")
|
||||
}}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.PREFERENCES }">{{
|
||||
$t("Preferences")
|
||||
}}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS,
|
||||
text: $t('Account'),
|
||||
},
|
||||
{
|
||||
name: RouteName.PREFERENCES,
|
||||
text: $t('Preferences'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div>
|
||||
<b-field :label="$t('Language')" label-for="setting-language">
|
||||
<b-select
|
||||
|
@ -1,35 +1,29 @@
|
||||
<template>
|
||||
<section class="section container" v-if="todo">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: todo.todoList.actor.preferredUsername,
|
||||
},
|
||||
}"
|
||||
>{{ todo.todoList.actor.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.TODO_LIST,
|
||||
params: { id: todo.todoList.id },
|
||||
}"
|
||||
>
|
||||
{{ todo.todoList.title }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link :to="{ name: RouteName.TODO }" aria-current="page">
|
||||
{{ todo.title }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(todo.todoList.actor),
|
||||
},
|
||||
text: displayName(todo.todoList.actor),
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LISTS,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(todo.todoList.actor),
|
||||
},
|
||||
text: $t('Task lists'),
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LIST,
|
||||
params: { id: todo.todoList.id },
|
||||
text: todo.todoList.title,
|
||||
},
|
||||
{ name: RouteName.TODO, text: todo.title },
|
||||
]"
|
||||
/>
|
||||
<full-todo :todo="todo" />
|
||||
</section>
|
||||
</template>
|
||||
@ -39,6 +33,7 @@ import { GET_TODO } from "@/graphql/todos";
|
||||
import { ITodo } from "@/types/todos";
|
||||
import FullTodo from "@/components/Todo/FullTodo.vue";
|
||||
import RouteName from "../../router/name";
|
||||
import { displayName, usernameWithDomain } from "@/types/actor";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@ -70,5 +65,9 @@ export default class Todo extends Vue {
|
||||
todo!: ITodo;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
}
|
||||
</script>
|
||||
|
@ -1,34 +1,24 @@
|
||||
<template>
|
||||
<section class="container section" v-if="todoList">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: todoList.actor.preferredUsername },
|
||||
}"
|
||||
>{{ todoList.actor.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.TODO_LISTS,
|
||||
params: { preferredUsername: todoList.actor.preferredUsername },
|
||||
}"
|
||||
>{{ $t("Task lists") }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }"
|
||||
>
|
||||
{{ todoList.title }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(todoList.actor) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LISTS,
|
||||
params: { preferredUsername: usernameWithDomain(todoList.actor) },
|
||||
text: $t('Task lists'),
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LIST,
|
||||
params: { id: todoList.id },
|
||||
text: todoList.title,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<h2 class="title">{{ todoList.title }}</h2>
|
||||
<div v-for="todo in todoList.todos.elements" :key="todo.id">
|
||||
<compact-todo :todo="todo" />
|
||||
@ -48,7 +38,7 @@ import { ITodo } from "@/types/todos";
|
||||
import { CREATE_TODO, FETCH_TODO_LIST } from "@/graphql/todos";
|
||||
import CompactTodo from "@/components/Todo/CompactTodo.vue";
|
||||
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
||||
import { IActor } from "@/types/actor";
|
||||
import { displayName, IActor, usernameWithDomain } from "@/types/actor";
|
||||
import { ITodoList } from "@/types/todolist";
|
||||
import RouteName from "../../router/name";
|
||||
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
|
||||
@ -89,6 +79,10 @@ export default class TodoList extends Vue {
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
async createNewTodo(): Promise<void> {
|
||||
await this.$apollo.mutate({
|
||||
mutation: CREATE_TODO,
|
||||
|
@ -1,27 +1,19 @@
|
||||
<template>
|
||||
<div class="container section" v-if="group">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ group.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.TODO_LISTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
>{{ $t("Task lists") }}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.GROUP,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: displayName(group),
|
||||
},
|
||||
{
|
||||
name: RouteName.TODO_LISTS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
text: $t('Task lists'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<p>
|
||||
{{
|
||||
@ -61,7 +53,7 @@
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { FETCH_GROUP } from "@/graphql/group";
|
||||
import { IGroup, usernameWithDomain } from "@/types/actor";
|
||||
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
|
||||
import { CREATE_TODO_LIST } from "@/graphql/todos";
|
||||
import CompactTodo from "@/components/Todo/CompactTodo.vue";
|
||||
import { ITodoList } from "@/types/todolist";
|
||||
@ -108,6 +100,8 @@ export default class TodoLists extends Vue {
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
displayName = displayName;
|
||||
|
||||
get todoLists(): ITodoList[] {
|
||||
return this.group.todoLists.elements;
|
||||
}
|
||||
|
22
js/tailwind.config.js
Normal file
22
js/tailwind.config.js
Normal 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")],
|
||||
};
|
2672
js/yarn.lock
2672
js/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -33,7 +33,10 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
|
||||
case Actors.get_actor_by_url(url, preload) do
|
||||
{:ok, %Actor{} = cached_actor} ->
|
||||
if Actors.needs_update?(cached_actor) do
|
||||
__MODULE__.make_actor_from_url(url, options)
|
||||
case __MODULE__.make_actor_from_url(url, options) do
|
||||
{:ok, %Actor{} = actor} -> {:ok, actor}
|
||||
{:error, _} -> {:ok, cached_actor}
|
||||
end
|
||||
else
|
||||
{:ok, cached_actor}
|
||||
end
|
||||
@ -83,11 +86,14 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
|
||||
Logger.debug("Finding or making actor from nickname #{nickname}")
|
||||
|
||||
case Actors.get_actor_by_name_with_preload(nickname, type) do
|
||||
%Actor{url: actor_url} = actor ->
|
||||
if Actors.needs_update?(actor) do
|
||||
make_actor_from_url(actor_url, preload: true)
|
||||
%Actor{url: actor_url} = cached_actor ->
|
||||
if Actors.needs_update?(cached_actor) do
|
||||
case __MODULE__.make_actor_from_url(actor_url, preload: true) do
|
||||
{:ok, %Actor{} = actor} -> {:ok, actor}
|
||||
{:error, _} -> {:ok, cached_actor}
|
||||
end
|
||||
else
|
||||
{:ok, actor}
|
||||
{:ok, cached_actor}
|
||||
end
|
||||
|
||||
nil ->
|
||||
@ -102,15 +108,15 @@ defmodule Mobilizon.Federation.ActivityPub.Actor do
|
||||
@doc """
|
||||
Create an actor inside our database from username, using WebFinger to find out its AP ID and then fetch it
|
||||
"""
|
||||
@spec make_actor_from_nickname(nickname :: String.t(), preload :: boolean) ::
|
||||
@spec make_actor_from_nickname(nickname :: String.t(), options :: Keyword.t()) ::
|
||||
{:ok, Actor.t()} | {:error, make_actor_errors | WebFinger.finger_errors()}
|
||||
def make_actor_from_nickname(nickname, preload \\ false) do
|
||||
def make_actor_from_nickname(nickname, options \\ []) do
|
||||
Logger.debug("Fingering actor from nickname #{nickname}")
|
||||
|
||||
case WebFinger.finger(nickname) do
|
||||
{:ok, url} when is_binary(url) ->
|
||||
Logger.debug("Matched #{nickname} to URL #{url}, now making actor")
|
||||
make_actor_from_url(url, preload: preload)
|
||||
make_actor_from_url(url, options)
|
||||
|
||||
{:error, e} ->
|
||||
{:error, e}
|
||||
|
@ -20,7 +20,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
|
||||
@doc """
|
||||
Get audience for an entity
|
||||
"""
|
||||
@spec get_audience(Entity.t()) :: audience()
|
||||
@spec get_audience(Entity.t() | Participant.t()) :: audience()
|
||||
def get_audience(%Event{} = event) do
|
||||
extract_actors_from_event(event)
|
||||
end
|
||||
|
@ -190,7 +190,7 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
|
||||
|
||||
# If we're handling an activity
|
||||
@spec handling_element(map()) :: {:ok, any, struct} | :error
|
||||
@spec handling_element(String.t()) :: {:ok, struct} | {:error, any()}
|
||||
@spec handling_element(String.t()) :: {:ok, struct} | {:ok, atom, struct} | {:error, any()}
|
||||
defp handling_element(%{"type" => activity_type} = data)
|
||||
when activity_type in ["Create", "Update", "Delete"] do
|
||||
object = get_in(data, ["object"])
|
||||
|
@ -125,7 +125,11 @@ defmodule Mobilizon.Federation.WebFinger do
|
||||
defp maybe_add_profile_page(data, _actor), do: data
|
||||
|
||||
@type finger_errors ::
|
||||
:host_not_found | :address_invalid | :http_error | :webfinger_information_not_json
|
||||
:host_not_found
|
||||
| :address_invalid
|
||||
| :http_error
|
||||
| :webfinger_information_not_json
|
||||
| :no_url_in_webfinger_data
|
||||
|
||||
@doc """
|
||||
Finger an actor to retreive it's ActivityPub ID/URL
|
||||
@ -144,6 +148,10 @@ defmodule Mobilizon.Federation.WebFinger do
|
||||
{:ok, %{"url" => url}} ->
|
||||
{:ok, url}
|
||||
|
||||
{:ok, _} ->
|
||||
Logger.debug("No URL found for actor from webfinger data")
|
||||
{:error, :no_url_in_webfinger_data}
|
||||
|
||||
{:error, err} ->
|
||||
Logger.debug("Couldn't process webfinger data for #{actor}")
|
||||
{:error, err}
|
||||
@ -158,11 +166,14 @@ defmodule Mobilizon.Federation.WebFinger do
|
||||
@spec fetch_webfinger_data(String.t()) ::
|
||||
{:ok, map()} | {:error, :webfinger_information_not_json | :http_error}
|
||||
defp fetch_webfinger_data(address) do
|
||||
Logger.debug("Calling WebfingerClient with #{inspect(address)}")
|
||||
|
||||
case WebfingerClient.get(address) do
|
||||
{:ok, %{body: body, status: code}} when code in 200..299 ->
|
||||
webfinger_from_json(body)
|
||||
|
||||
_ ->
|
||||
err ->
|
||||
Logger.debug("Failed to fetch webfinger data #{inspect(err)}")
|
||||
{:error, :http_error}
|
||||
end
|
||||
end
|
||||
@ -173,12 +184,14 @@ defmodule Mobilizon.Federation.WebFinger do
|
||||
case apply_webfinger_endpoint(actor) do
|
||||
address when is_binary(address) ->
|
||||
if address_invalid(address) do
|
||||
Logger.info("Webfinger endpoint seems to be an invalid URL #{inspect(address)}")
|
||||
{:error, :address_invalid}
|
||||
else
|
||||
{:ok, address}
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.info("Host not found in actor address #{inspect(actor)}")
|
||||
{:error, :host_not_found}
|
||||
end
|
||||
end
|
||||
@ -188,12 +201,15 @@ defmodule Mobilizon.Federation.WebFinger do
|
||||
@spec find_webfinger_endpoint(String.t()) ::
|
||||
{:ok, String.t()} | {:error, :link_not_found} | {:error, any()}
|
||||
defp find_webfinger_endpoint(domain) when is_binary(domain) do
|
||||
Logger.debug("Calling HostMetaClient for #{domain}")
|
||||
|
||||
with {:ok, %Tesla.Env{status: 200, body: body}} <-
|
||||
HostMetaClient.get("http://#{domain}/.well-known/host-meta"),
|
||||
HostMetaClient.get("https://#{domain}/.well-known/host-meta"),
|
||||
link_template when is_binary(link_template) <- find_link_from_template(body) do
|
||||
{:ok, link_template}
|
||||
else
|
||||
{:ok, %Tesla.Env{status: 404}} -> {:error, :entity_not_found}
|
||||
{:ok, %Tesla.Env{}} -> {:error, :http_error}
|
||||
{:error, :link_not_found} -> {:error, :link_not_found}
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
@ -204,10 +220,12 @@ defmodule Mobilizon.Federation.WebFinger do
|
||||
with {:ok, domain} <- domain_from_federated_actor(actor) do
|
||||
case find_webfinger_endpoint(domain) do
|
||||
{:ok, link_template} ->
|
||||
Logger.debug("Using webfinger location provided by host-meta endpoint")
|
||||
String.replace(link_template, "{uri}", "acct:#{actor}")
|
||||
|
||||
_ ->
|
||||
"http://#{domain}/.well-known/webfinger?resource=acct:#{actor}"
|
||||
Logger.debug("Using default webfinger location")
|
||||
"https://#{domain}/.well-known/webfinger?resource=acct:#{actor}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -233,6 +251,10 @@ defmodule Mobilizon.Federation.WebFinger do
|
||||
{"application/activity+json", "self"} ->
|
||||
Map.put(data, "url", link["href"])
|
||||
|
||||
{nil, _rel} ->
|
||||
Logger.debug("No type declared for the following link #{inspect(link)}")
|
||||
data
|
||||
|
||||
_ ->
|
||||
Logger.debug(fn ->
|
||||
"Unhandled type to finger: #{inspect(link["type"])}"
|
||||
|
@ -41,7 +41,7 @@ defmodule Mobilizon.GraphQL.API.Follows do
|
||||
"We're trying to accept a follow: #{followed_url} is accepting #{follower_url} follow request."
|
||||
)
|
||||
|
||||
case Actors.is_following(follower, followed) do
|
||||
case Actors.check_follow(follower, followed) do
|
||||
%Follower{approved: false} = follow ->
|
||||
Actions.Accept.accept(
|
||||
:follow,
|
||||
@ -68,8 +68,9 @@ defmodule Mobilizon.GraphQL.API.Follows do
|
||||
"We're trying to reject a follow: #{followed_url} is rejecting #{follower_url} follow request."
|
||||
)
|
||||
|
||||
case Actors.is_following(follower, followed) do
|
||||
%Follower{approved: false} ->
|
||||
case Actors.check_follow(follower, followed) do
|
||||
%Follower{approved: false} = follow ->
|
||||
Actors.delete_follower(follow)
|
||||
{:error, "Follow already rejected"}
|
||||
|
||||
%Follower{} = follow ->
|
||||
|
@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||
|
||||
import Mobilizon.Users.Guards
|
||||
|
||||
alias Mobilizon.{Actors, Admin, Config, Events}
|
||||
alias Mobilizon.{Actors, Admin, Config, Events, Instances, Users}
|
||||
alias Mobilizon.Actors.{Actor, Follower}
|
||||
alias Mobilizon.Admin.{ActionLog, Setting}
|
||||
alias Mobilizon.Cldr.Language
|
||||
@ -14,9 +14,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Federation.ActivityPub.{Actions, Relay}
|
||||
alias Mobilizon.Reports.{Note, Report}
|
||||
alias Mobilizon.Service.Auth.Authenticator
|
||||
alias Mobilizon.Service.Statistics
|
||||
alias Mobilizon.Storage.Page
|
||||
alias Mobilizon.Users.User
|
||||
alias Mobilizon.Web.Email
|
||||
import Mobilizon.Web.Gettext
|
||||
require Logger
|
||||
|
||||
@ -281,6 +283,114 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||
dgettext("errors", "You need to be logged-in and an administrator to save admin settings")}
|
||||
end
|
||||
|
||||
@spec update_user(any, map(), Absinthe.Resolution.t()) ::
|
||||
{:error, :invalid_argument | :user_not_found | binary | Ecto.Changeset.t()}
|
||||
| {:ok, Mobilizon.Users.User.t()}
|
||||
def update_user(_parent, %{id: id, notify: notify} = args, %{
|
||||
context: %{current_user: %User{role: role}}
|
||||
})
|
||||
when is_admin(role) do
|
||||
case Users.get_user(id) do
|
||||
nil ->
|
||||
{:error, :user_not_found}
|
||||
|
||||
%User{} = user ->
|
||||
case args |> Map.drop([:notify, :id]) |> Map.keys() do
|
||||
[] ->
|
||||
{:error, :invalid_argument}
|
||||
|
||||
[change | _] ->
|
||||
case change do
|
||||
:email -> change_email(user, Map.get(args, :email), notify)
|
||||
:role -> change_role(user, Map.get(args, :role), notify)
|
||||
:confirmed -> confirm_user(user, Map.get(args, :confirmed), notify)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_user(_parent, _args, _resolution) do
|
||||
{:error,
|
||||
dgettext("errors", "You need to be logged-in and an administrator to edit an user's details")}
|
||||
end
|
||||
|
||||
@spec change_email(User.t(), String.t(), boolean()) :: {:ok, User.t()} | {:error, String.t()}
|
||||
defp change_email(%User{email: old_email} = user, new_email, notify) do
|
||||
if Authenticator.can_change_email?(user) do
|
||||
if new_email != old_email do
|
||||
if Email.Checker.valid?(new_email) do
|
||||
case Users.update_user(user, %{email: new_email}) do
|
||||
{:ok, %User{} = updated_user} ->
|
||||
if notify do
|
||||
updated_user
|
||||
|> Email.Admin.user_email_change_old(old_email)
|
||||
|> Email.Mailer.send_email_later()
|
||||
|
||||
updated_user
|
||||
|> Email.Admin.user_email_change_new(old_email)
|
||||
|> Email.Mailer.send_email_later()
|
||||
end
|
||||
|
||||
{:ok, updated_user}
|
||||
|
||||
{:error, %Ecto.Changeset{} = err} ->
|
||||
Logger.debug(inspect(err))
|
||||
{:error, dgettext("errors", "Failed to update user email")}
|
||||
end
|
||||
else
|
||||
{:error, dgettext("errors", "The new email doesn't seem to be valid")}
|
||||
end
|
||||
else
|
||||
{:error, dgettext("errors", "The new email must be different")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec change_role(User.t(), Mobilizon.Users.UserRole.t(), boolean()) ::
|
||||
{:ok, User.t()} | {:error, String.t() | Ecto.Changeset.t()}
|
||||
defp change_role(%User{role: old_role} = user, new_role, notify) do
|
||||
if old_role != new_role do
|
||||
with {:ok, %User{} = user} <- Users.update_user(user, %{role: new_role}) do
|
||||
if notify do
|
||||
user
|
||||
|> Email.Admin.user_role_change(old_role)
|
||||
|> Email.Mailer.send_email_later()
|
||||
end
|
||||
|
||||
{:ok, user}
|
||||
end
|
||||
else
|
||||
{:error, dgettext("errors", "The new role must be different")}
|
||||
end
|
||||
end
|
||||
|
||||
@spec confirm_user(User.t(), boolean(), boolean()) ::
|
||||
{:ok, User.t()} | {:error, String.t() | Ecto.Changeset.t()}
|
||||
defp confirm_user(%User{confirmed_at: nil} = user, true, notify) do
|
||||
with {:ok, %User{} = user} <-
|
||||
Users.update_user(user, %{
|
||||
confirmed_at: DateTime.utc_now(),
|
||||
confirmation_sent_at: nil,
|
||||
confirmation_token: nil
|
||||
}) do
|
||||
if notify do
|
||||
user
|
||||
|> Email.Admin.user_confirmation()
|
||||
|> Email.Mailer.send_email_later()
|
||||
end
|
||||
|
||||
{:ok, user}
|
||||
end
|
||||
end
|
||||
|
||||
defp confirm_user(%User{confirmed_at: %DateTime{}} = _user, true, _notify) do
|
||||
{:error, dgettext("errors", "Can't confirm an already confirmed user")}
|
||||
end
|
||||
|
||||
defp confirm_user(_user, _confirm, _notify) do
|
||||
{:error, dgettext("errors", "Deconfirming users is not supported")}
|
||||
end
|
||||
|
||||
@spec list_relay_followers(any(), map(), Absinthe.Resolution.t()) ::
|
||||
{:ok, Page.t(Follower.t())} | {:error, :unauthorized | :unauthenticated}
|
||||
def list_relay_followers(
|
||||
@ -329,16 +439,81 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||
{:error, :unauthenticated}
|
||||
end
|
||||
|
||||
def get_instances(
|
||||
_parent,
|
||||
args,
|
||||
%{
|
||||
context: %{current_user: %User{role: role}}
|
||||
}
|
||||
)
|
||||
when is_admin(role) do
|
||||
{:ok,
|
||||
Instances.instances(
|
||||
args
|
||||
|> Keyword.new()
|
||||
|> Keyword.take([
|
||||
:page,
|
||||
:limit,
|
||||
:order_by,
|
||||
:direction,
|
||||
:filter_domain,
|
||||
:filter_follow_status,
|
||||
:filter_suspend_status
|
||||
])
|
||||
)}
|
||||
end
|
||||
|
||||
def get_instances(_parent, _args, %{context: %{current_user: %User{}}}) do
|
||||
{:error, :unauthorized}
|
||||
end
|
||||
|
||||
def get_instances(_parent, _args, _resolution) do
|
||||
{:error, :unauthenticated}
|
||||
end
|
||||
|
||||
def get_instance(_parent, %{domain: domain}, %{
|
||||
context: %{current_user: %User{role: role}}
|
||||
})
|
||||
when is_admin(role) do
|
||||
has_relay = Actors.has_relay?(domain)
|
||||
remote_relay = Actors.get_actor_by_name("relay@#{domain}")
|
||||
local_relay = Relay.get_actor()
|
||||
|
||||
result = %{
|
||||
has_relay: has_relay,
|
||||
follower_status: follow_status(remote_relay, local_relay),
|
||||
followed_status: follow_status(local_relay, remote_relay)
|
||||
}
|
||||
|
||||
{:ok, Map.merge(Instances.instance(domain), result)}
|
||||
end
|
||||
|
||||
def get_instance(_parent, _args, %{context: %{current_user: %User{}}}) do
|
||||
{:error, :unauthorized}
|
||||
end
|
||||
|
||||
def get_instance(_parent, _args, _resolution) do
|
||||
{:error, :unauthenticated}
|
||||
end
|
||||
|
||||
def create_instance(
|
||||
parent,
|
||||
%{domain: domain} = args,
|
||||
%{context: %{current_user: %User{role: role}}} = resolution
|
||||
)
|
||||
when is_admin(role) do
|
||||
with {:ok, _activity, _follow} <- Relay.follow(domain) do
|
||||
Instances.refresh()
|
||||
get_instance(parent, args, resolution)
|
||||
end
|
||||
end
|
||||
|
||||
@spec create_relay(any(), map(), Absinthe.Resolution.t()) ::
|
||||
{:ok, Follower.t()} | {:error, any()}
|
||||
def create_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
|
||||
when is_admin(role) do
|
||||
case Relay.follow(address) do
|
||||
{:ok, _activity, follow} ->
|
||||
{:ok, follow}
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
with {:ok, _activity, follow} <- Relay.follow(address) do
|
||||
{:ok, follow}
|
||||
end
|
||||
end
|
||||
|
||||
@ -346,12 +521,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||
{:ok, Follower.t()} | {:error, any()}
|
||||
def remove_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
|
||||
when is_admin(role) do
|
||||
case Relay.unfollow(address) do
|
||||
{:ok, _activity, follow} ->
|
||||
{:ok, follow}
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
with {:ok, _activity, follow} <- Relay.unfollow(address) do
|
||||
{:ok, follow}
|
||||
end
|
||||
end
|
||||
|
||||
@ -363,12 +534,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||
%{context: %{current_user: %User{role: role}}}
|
||||
)
|
||||
when is_admin(role) do
|
||||
case Relay.accept(address) do
|
||||
{:ok, _activity, follow} ->
|
||||
{:ok, follow}
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
with {:ok, _activity, follow} <- Relay.accept(address) do
|
||||
{:ok, follow}
|
||||
end
|
||||
end
|
||||
|
||||
@ -380,12 +547,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||
%{context: %{current_user: %User{role: role}}}
|
||||
)
|
||||
when is_admin(role) do
|
||||
case Relay.reject(address) do
|
||||
{:ok, _activity, follow} ->
|
||||
{:ok, follow}
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
with {:ok, _activity, follow} <- Relay.reject(address) do
|
||||
{:ok, follow}
|
||||
end
|
||||
end
|
||||
|
||||
@ -425,4 +588,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@spec follow_status(Actor.t() | nil, Actor.t() | nil) :: :approved | :pending | :none
|
||||
defp follow_status(follower, followed) when follower != nil and followed != nil do
|
||||
case Actors.check_follow(follower, followed) do
|
||||
%Follower{approved: true} -> :approved
|
||||
%Follower{approved: false} -> :pending
|
||||
_ -> :none
|
||||
end
|
||||
end
|
||||
|
||||
defp follow_status(_, _), do: :none
|
||||
end
|
||||
|
@ -17,11 +17,19 @@ defmodule Mobilizon.GraphQL.Resolvers.Report do
|
||||
{:ok, Page.t(Report.t())} | {:error, String.t()}
|
||||
def list_reports(
|
||||
_parent,
|
||||
%{page: page, limit: limit, status: status},
|
||||
%{page: page, limit: limit} = args,
|
||||
%{context: %{current_user: %User{role: role}}}
|
||||
)
|
||||
when is_moderator(role) do
|
||||
{:ok, Mobilizon.Reports.list_reports(page, limit, :updated_at, :desc, status)}
|
||||
{:ok,
|
||||
Mobilizon.Reports.list_reports(
|
||||
page: page,
|
||||
limit: limit,
|
||||
sort: :updated_at,
|
||||
direction: :desc,
|
||||
status: Map.get(args, :status),
|
||||
domain: Map.get(args, :domain)
|
||||
)}
|
||||
end
|
||||
|
||||
def list_reports(_parent, _args, _resolution) do
|
||||
|
@ -48,11 +48,11 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
|
||||
{:ok, Page.t(User.t())} | {:error, :unauthorized}
|
||||
def list_users(
|
||||
_parent,
|
||||
%{email: email, page: page, limit: limit, sort: sort, direction: direction},
|
||||
args,
|
||||
%{context: %{current_user: %User{role: role}}}
|
||||
)
|
||||
when is_moderator(role) do
|
||||
{:ok, Users.list_users(email, page, limit, sort, direction)}
|
||||
{:ok, Users.list_users(Keyword.new(args))}
|
||||
end
|
||||
|
||||
def list_users(_parent, _args, _resolution) do
|
||||
|
@ -153,6 +153,80 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
||||
value(:custom, as: "CUSTOM", description: "Custom privacy policy text")
|
||||
end
|
||||
|
||||
enum :instance_follow_status do
|
||||
value(:approved, description: "The instance follow was approved")
|
||||
value(:pending, description: "The instance follow is still pending")
|
||||
value(:none, description: "There's no instance follow etablished")
|
||||
end
|
||||
|
||||
enum :instances_sort_fields do
|
||||
value(:event_count)
|
||||
value(:person_count)
|
||||
value(:group_count)
|
||||
value(:followers_count)
|
||||
value(:followings_count)
|
||||
value(:reports_count)
|
||||
value(:media_size)
|
||||
end
|
||||
|
||||
enum :instance_filter_follow_status do
|
||||
value(:all)
|
||||
value(:following)
|
||||
value(:followed)
|
||||
end
|
||||
|
||||
enum :instance_filter_suspend_status do
|
||||
value(:all)
|
||||
value(:suspended)
|
||||
end
|
||||
|
||||
@desc """
|
||||
An instance representation
|
||||
"""
|
||||
object :instance do
|
||||
field(:domain, :id, description: "The domain name of the instance")
|
||||
field(:has_relay, :boolean, description: "Whether this instance has a Mobilizon relay actor")
|
||||
field(:follower_status, :instance_follow_status, description: "Do we follow this instance")
|
||||
field(:followed_status, :instance_follow_status, description: "Does this instance follow us?")
|
||||
|
||||
field(:event_count, :integer, description: "The number of events on this instance we know of")
|
||||
|
||||
field(:person_count, :integer,
|
||||
description: "The number of profiles on this instance we know of"
|
||||
)
|
||||
|
||||
field(:group_count, :integer, description: "The number of grouo on this instance we know of")
|
||||
|
||||
field(:followers_count, :integer,
|
||||
description: "The number of their profiles who follow our groups"
|
||||
)
|
||||
|
||||
field(:followings_count, :integer,
|
||||
description: "The number of our profiles who follow their groups"
|
||||
)
|
||||
|
||||
field(:reports_count, :integer,
|
||||
description: "The number of reports made against profiles from this instance"
|
||||
)
|
||||
|
||||
field(:media_size, :integer,
|
||||
description: "The size of all the media files sent by actors from this instance"
|
||||
)
|
||||
|
||||
field(:has_relay, :boolean,
|
||||
description:
|
||||
"Whether this instance has a relay, meaning that it's a Mobilizon instance that we can follow"
|
||||
)
|
||||
end
|
||||
|
||||
@desc """
|
||||
A paginated list of instances
|
||||
"""
|
||||
object :paginated_instance_list do
|
||||
field(:elements, list_of(:instance), description: "A list of instances")
|
||||
field(:total, :integer, description: "The total number of instances in the list")
|
||||
end
|
||||
|
||||
object :admin_queries do
|
||||
@desc "Get the list of action logs"
|
||||
field :action_logs, type: :paginated_action_log_list do
|
||||
@ -226,9 +300,59 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
||||
arg(:direction, :string, default_value: :desc, description: "The sorting direction")
|
||||
resolve(&Admin.list_relay_followings/3)
|
||||
end
|
||||
|
||||
@desc """
|
||||
List instances
|
||||
"""
|
||||
field :instances, :paginated_instance_list do
|
||||
arg(:page, :integer,
|
||||
default_value: 1,
|
||||
description: "The page in the paginated relay followings list"
|
||||
)
|
||||
|
||||
arg(:limit, :integer,
|
||||
default_value: 10,
|
||||
description: "The limit of relay followings per page"
|
||||
)
|
||||
|
||||
arg(:order_by, :instances_sort_fields,
|
||||
default_value: :event_count,
|
||||
description: "The field to order by the list"
|
||||
)
|
||||
|
||||
arg(:filter_domain, :string, default_value: nil, description: "Filter by domain")
|
||||
|
||||
arg(:filter_follow_status, :instance_filter_follow_status,
|
||||
default_value: :all,
|
||||
description: "Whether or not to filter instances by the follow status"
|
||||
)
|
||||
|
||||
arg(:filter_suspend_status, :instance_filter_suspend_status,
|
||||
default_value: :all,
|
||||
description: "Whether or not to filter instances by the suspended status"
|
||||
)
|
||||
|
||||
arg(:direction, :string, default_value: :desc, description: "The sorting direction")
|
||||
resolve(&Admin.get_instances/3)
|
||||
end
|
||||
|
||||
@desc """
|
||||
Get an instance's details
|
||||
"""
|
||||
field :instance, :instance do
|
||||
arg(:domain, non_null(:id), description: "The instance domain")
|
||||
resolve(&Admin.get_instance/3)
|
||||
end
|
||||
end
|
||||
|
||||
object :admin_mutations do
|
||||
@desc "Add an instance subscription"
|
||||
field :add_instance, type: :instance do
|
||||
arg(:domain, non_null(:string), description: "The instance domain to add")
|
||||
|
||||
resolve(&Admin.create_instance/3)
|
||||
end
|
||||
|
||||
@desc "Add a relay subscription"
|
||||
field :add_relay, type: :follower do
|
||||
arg(:address, non_null(:string), description: "The relay hostname to add")
|
||||
@ -285,5 +409,22 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
||||
|
||||
resolve(&Admin.save_settings/3)
|
||||
end
|
||||
|
||||
@desc """
|
||||
For an admin to update an user
|
||||
"""
|
||||
field :admin_update_user, type: :user do
|
||||
arg(:id, non_null(:id), description: "The user's ID")
|
||||
arg(:email, :string, description: "The user's new email")
|
||||
arg(:confirmed, :boolean, description: "Manually confirm the user's account")
|
||||
arg(:role, :user_role, description: "Set user's new role")
|
||||
|
||||
arg(:notify, :boolean,
|
||||
default_value: false,
|
||||
description: "Whether or not to notify the user of the change"
|
||||
)
|
||||
|
||||
resolve(&Admin.update_user/3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -67,6 +67,7 @@ defmodule Mobilizon.GraphQL.Schema.ReportType do
|
||||
|
||||
arg(:limit, :integer, default_value: 10, description: "The limit of reports per page")
|
||||
arg(:status, :report_status, default_value: :open, description: "Filter reports by status")
|
||||
arg(:domain, :string, default_value: nil, description: "Filter reports by domain name")
|
||||
resolve(&Report.list_reports/3)
|
||||
end
|
||||
|
||||
|
@ -280,6 +280,11 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
|
||||
@desc "List instance users"
|
||||
field :users, :users do
|
||||
arg(:email, :string, default_value: "", description: "Filter users by email")
|
||||
|
||||
arg(:current_sign_in_ip, :string,
|
||||
description: "Filter users by current signed-in IP address"
|
||||
)
|
||||
|
||||
arg(:page, :integer, default_value: 1, description: "The page in the paginated users list")
|
||||
arg(:limit, :integer, default_value: 10, description: "The limit of users per page")
|
||||
|
||||
|
@ -130,7 +130,7 @@ defmodule Mix.Tasks.Mobilizon.Instance do
|
||||
options,
|
||||
:listen_port,
|
||||
"What port will the app listen to (leave it if you are using the default setup with nginx)?",
|
||||
4000
|
||||
"4000"
|
||||
)
|
||||
|
||||
instance_secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64)
|
||||
|
@ -67,10 +67,6 @@ defmodule Mix.Tasks.Mobilizon.Users.New do
|
||||
{:error, %Ecto.Changeset{errors: errors}} ->
|
||||
shell_error(inspect(errors))
|
||||
shell_error("User has not been created because of the above reason.")
|
||||
|
||||
err ->
|
||||
shell_error(inspect(err))
|
||||
shell_error("User has not been created because of an unknown reason.")
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -1176,7 +1176,7 @@ defmodule Mobilizon.Actors do
|
||||
if followed.suspended do
|
||||
{:error, :followed_suspended}
|
||||
else
|
||||
case is_following(follower, followed) do
|
||||
case check_follow(follower, followed) do
|
||||
%Follower{} ->
|
||||
{:error, :already_following}
|
||||
|
||||
@ -1202,7 +1202,7 @@ defmodule Mobilizon.Actors do
|
||||
@spec unfollow(Actor.t(), Actor.t()) ::
|
||||
{:ok, Follower.t()} | {:error, Ecto.Changeset.t() | String.t()}
|
||||
def unfollow(%Actor{} = followed, %Actor{} = follower) do
|
||||
case {:already_following, is_following(follower, followed)} do
|
||||
case {:already_following, check_follow(follower, followed)} do
|
||||
{:already_following, %Follower{} = follow} ->
|
||||
delete_follower(follow)
|
||||
|
||||
@ -1214,8 +1214,8 @@ defmodule Mobilizon.Actors do
|
||||
@doc """
|
||||
Checks whether an actor is following another actor.
|
||||
"""
|
||||
@spec is_following(Actor.t(), Actor.t()) :: Follower.t() | nil
|
||||
def is_following(%Actor{} = follower_actor, %Actor{} = followed_actor) do
|
||||
@spec check_follow(Actor.t(), Actor.t()) :: Follower.t() | nil
|
||||
def check_follow(%Actor{} = follower_actor, %Actor{} = followed_actor) do
|
||||
get_follower_by_followed_and_following(followed_actor, follower_actor)
|
||||
end
|
||||
|
||||
@ -1256,6 +1256,16 @@ defmodule Mobilizon.Actors do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec has_relay?(String.t()) :: boolean()
|
||||
def has_relay?(domain) do
|
||||
Actor
|
||||
|> where(
|
||||
[a],
|
||||
a.preferred_username == "relay" and a.domain == ^domain and a.type == :Application
|
||||
)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@spec delete_files_if_media_changed(Ecto.Changeset.t()) :: Ecto.Changeset.t()
|
||||
defp delete_files_if_media_changed(%Ecto.Changeset{changes: changes, data: data} = changeset) do
|
||||
Enum.each([:avatar, :banner], fn key ->
|
||||
@ -1465,7 +1475,7 @@ defmodule Mobilizon.Actors do
|
||||
|> where([_q, ..., a], like(a.name, ^"%#{name}%") or like(a.preferred_username, ^"%#{name}%"))
|
||||
end
|
||||
|
||||
@spec join_members_actor(Ecto.Query.t()) :: Ecto.Query.t()
|
||||
@spec join_members_actor(Ecto.Queryable.t()) :: Ecto.Query.t()
|
||||
defp join_members_actor(query) do
|
||||
join(query, :inner, [q], a in Actor, on: q.actor_id == a.id)
|
||||
end
|
||||
|
19
lib/mobilizon/instances/instance.ex
Normal file
19
lib/mobilizon/instances/instance.ex
Normal 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
|
115
lib/mobilizon/instances/instances.ex
Normal file
115
lib/mobilizon/instances/instances.ex
Normal 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
|
@ -8,6 +8,7 @@ defmodule Mobilizon.Reports do
|
||||
|
||||
import Mobilizon.Storage.Ecto
|
||||
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Reports.{Note, Report}
|
||||
alias Mobilizon.Storage.{Page, Repo}
|
||||
|
||||
@ -49,17 +50,18 @@ defmodule Mobilizon.Reports do
|
||||
@doc """
|
||||
Returns the list of reports.
|
||||
"""
|
||||
@spec list_reports(integer | nil, integer | nil, atom, atom, ReportStatus.t()) ::
|
||||
Page.t(Report.t())
|
||||
def list_reports(
|
||||
page \\ nil,
|
||||
limit \\ nil,
|
||||
sort \\ :updated_at,
|
||||
direction \\ :asc,
|
||||
status \\ :open
|
||||
) do
|
||||
@spec list_reports(Keyword.t()) :: Page.t(Report.t())
|
||||
def list_reports(options) do
|
||||
page = Keyword.get(options, :page)
|
||||
limit = Keyword.get(options, :limit)
|
||||
sort = Keyword.get(options, :sort, :updated_at)
|
||||
direction = Keyword.get(options, :direction, :asc)
|
||||
status = Keyword.get(options, :status, :open)
|
||||
domain = Keyword.get(options, :domain)
|
||||
|
||||
status
|
||||
|> list_reports_query()
|
||||
|> filter_domain_name(domain)
|
||||
|> sort(sort, direction)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
@ -99,11 +101,19 @@ defmodule Mobilizon.Reports do
|
||||
|
||||
@spec list_reports_query(ReportStatus.t()) :: Ecto.Query.t()
|
||||
defp list_reports_query(status) do
|
||||
from(
|
||||
r in Report,
|
||||
preload: [:reported, :reporter, :manager, :event, :comments, :notes],
|
||||
where: r.status == ^status
|
||||
)
|
||||
Report
|
||||
|> preload([:reported, :reporter, :manager, :event, :comments, :notes])
|
||||
|> where([r], r.status == ^status)
|
||||
end
|
||||
|
||||
@spec filter_domain_name(Ecto.Queryable.t(), String.t() | nil) :: Ecto.Queryable.t()
|
||||
defp filter_domain_name(query, nil), do: query
|
||||
defp filter_domain_name(query, ""), do: query
|
||||
|
||||
defp filter_domain_name(query, domain) do
|
||||
query
|
||||
|> join(:inner, [r], a in Actor, on: a.id == r.reported_id)
|
||||
|> where([_r, a], like(a.domain, ^"%#{domain}%"))
|
||||
end
|
||||
|
||||
@spec count_reports_query :: Ecto.Query.t()
|
||||
|
@ -272,23 +272,14 @@ defmodule Mobilizon.Users do
|
||||
@doc """
|
||||
Returns the list of users.
|
||||
"""
|
||||
@spec list_users(String.t(), integer | nil, integer | nil, atom, atom) ::
|
||||
Page.t(User.t())
|
||||
def list_users(email, page, limit \\ nil, sort, direction)
|
||||
|
||||
def list_users("", page, limit, sort, direction) do
|
||||
@spec list_users(Keyword.t()) :: Page.t(User.t())
|
||||
def list_users(options) do
|
||||
User
|
||||
|> sort(sort, direction)
|
||||
|> filter_by_email(Keyword.get(options, :email))
|
||||
|> filter_by_ip(Keyword.get(options, :current_sign_in_ip))
|
||||
|> sort(Keyword.get(options, :sort), Keyword.get(options, :direction))
|
||||
|> preload([u], [:actors, :feed_tokens, :settings, :default_actor])
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
def list_users(email, page, limit, sort, direction) do
|
||||
User
|
||||
|> where([u], ilike(u.email, ^"%#{email}%"))
|
||||
|> sort(sort, direction)
|
||||
|> preload([u], [:actors, :feed_tokens, :settings, :default_actor])
|
||||
|> Page.build_page(page, limit)
|
||||
|> Page.build_page(Keyword.get(options, :page), Keyword.get(options, :limit))
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -527,4 +518,16 @@ defmodule Mobilizon.Users do
|
||||
defp update_user_default_actor_query(user_id) do
|
||||
where(User, [u], u.id == ^user_id)
|
||||
end
|
||||
|
||||
@spec filter_by_email(Ecto.Queryable.t(), String.t() | nil) :: Ecto.Query.t()
|
||||
defp filter_by_email(query, nil), do: query
|
||||
defp filter_by_email(query, ""), do: query
|
||||
defp filter_by_email(query, email), do: where(query, [q], ilike(q.email, ^"%#{email}%"))
|
||||
|
||||
@spec filter_by_ip(Ecto.Queryable.t(), String.t() | nil) :: Ecto.Query.t()
|
||||
defp filter_by_ip(query, nil), do: query
|
||||
defp filter_by_ip(query, ""), do: query
|
||||
|
||||
defp filter_by_ip(query, current_sign_in_ip),
|
||||
do: where(query, [q], q.current_sign_in_ip == ^current_sign_in_ip)
|
||||
end
|
||||
|
@ -163,6 +163,7 @@ defmodule Mobilizon.Service.Export.ICalendar do
|
||||
end
|
||||
|
||||
defp shift_tz(%DateTime{} = date, _), do: date
|
||||
defp shift_tz(nil, _), do: nil
|
||||
|
||||
defp organizer(%Event{attributed_to: %Actor{} = group}) do
|
||||
Actor.display_name(group)
|
||||
|
@ -8,7 +8,12 @@ defmodule Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker do
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Job{}) do
|
||||
if Mobilizon.Config.get!([:instance, :remove_unconfirmed_users]) and should_perform?() do
|
||||
remove_unconfirmed_users =
|
||||
:mobilizon
|
||||
|> Application.get_env(:instance)
|
||||
|> Keyword.get(:remove_unconfirmed_users, false)
|
||||
|
||||
if remove_unconfirmed_users and should_perform?() do
|
||||
CleanUnconfirmedUsers.clean()
|
||||
end
|
||||
end
|
||||
|
@ -41,7 +41,7 @@ defmodule Mobilizon.Service.Workers.Helper do
|
||||
|
||||
alias Oban.Job
|
||||
|
||||
@spec enqueue(String.t(), map(), Keyword.t()) ::
|
||||
@spec enqueue(String.t() | :atom, map(), Keyword.t()) ::
|
||||
{:ok, Job.t()} | {:error, Ecto.Changeset.t()}
|
||||
def enqueue(operation, params, worker_args \\ []) do
|
||||
params = Map.merge(%{"op" => operation}, params)
|
||||
|
31
lib/service/workers/refresh_instances.ex
Normal file
31
lib/service/workers/refresh_instances.ex
Normal 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
|
@ -32,4 +32,100 @@ defmodule Mobilizon.Web.Email.Admin do
|
||||
|> assign(:report, report)
|
||||
|> render(:report)
|
||||
end
|
||||
|
||||
@spec user_email_change_old(User.t(), String.t()) :: Bamboo.Email.t()
|
||||
def user_email_change_old(
|
||||
%User{
|
||||
locale: user_locale,
|
||||
email: new_email
|
||||
},
|
||||
old_email
|
||||
) do
|
||||
Gettext.put_locale(user_locale)
|
||||
|
||||
subject =
|
||||
gettext(
|
||||
"An administrator manually changed the email attached to your account on %{instance}",
|
||||
instance: Config.instance_name()
|
||||
)
|
||||
|
||||
Email.base_email(to: old_email, subject: subject)
|
||||
|> assign(:locale, user_locale)
|
||||
|> assign(:subject, subject)
|
||||
|> assign(:new_email, new_email)
|
||||
|> assign(:old_email, old_email)
|
||||
|> assign(:offer_unsupscription, false)
|
||||
|> render(:admin_user_email_changed_old)
|
||||
end
|
||||
|
||||
@spec user_email_change_new(User.t(), String.t()) :: Bamboo.Email.t()
|
||||
def user_email_change_new(
|
||||
%User{
|
||||
locale: user_locale,
|
||||
email: new_email
|
||||
},
|
||||
old_email
|
||||
) do
|
||||
Gettext.put_locale(user_locale)
|
||||
|
||||
subject =
|
||||
gettext(
|
||||
"An administrator manually changed the email attached to your account on %{instance}",
|
||||
instance: Config.instance_name()
|
||||
)
|
||||
|
||||
Email.base_email(to: new_email, subject: subject)
|
||||
|> assign(:locale, user_locale)
|
||||
|> assign(:subject, subject)
|
||||
|> assign(:old_email, old_email)
|
||||
|> assign(:new_email, new_email)
|
||||
|> assign(:offer_unsupscription, false)
|
||||
|> render(:admin_user_email_changed_new)
|
||||
end
|
||||
|
||||
@spec user_role_change(User.t(), atom()) :: Bamboo.Email.t()
|
||||
def user_role_change(
|
||||
%User{
|
||||
locale: user_locale,
|
||||
email: email,
|
||||
role: new_role
|
||||
},
|
||||
old_role
|
||||
) do
|
||||
Gettext.put_locale(user_locale)
|
||||
|
||||
subject =
|
||||
gettext(
|
||||
"An administrator updated your role on %{instance}",
|
||||
instance: Config.instance_name()
|
||||
)
|
||||
|
||||
Email.base_email(to: email, subject: subject)
|
||||
|> assign(:locale, user_locale)
|
||||
|> assign(:subject, subject)
|
||||
|> assign(:old_role, old_role)
|
||||
|> assign(:new_role, new_role)
|
||||
|> assign(:offer_unsupscription, false)
|
||||
|> render(:admin_user_role_changed)
|
||||
end
|
||||
|
||||
@spec user_confirmation(User.t()) :: Bamboo.Email.t()
|
||||
def user_confirmation(%User{
|
||||
locale: user_locale,
|
||||
email: email
|
||||
}) do
|
||||
Gettext.put_locale(user_locale)
|
||||
|
||||
subject =
|
||||
gettext(
|
||||
"An administrator confirmed your account on %{instance}",
|
||||
instance: Config.instance_name()
|
||||
)
|
||||
|
||||
Email.base_email(to: email, subject: subject)
|
||||
|> assign(:locale, user_locale)
|
||||
|> assign(:subject, subject)
|
||||
|> assign(:offer_unsupscription, false)
|
||||
|> render(:admin_user_confirmation)
|
||||
end
|
||||
end
|
||||
|
8
lib/web/templates/email/admin/_role.html.heex
Normal file
8
lib/web/templates/email/admin/_role.html.heex
Normal file
@ -0,0 +1,8 @@
|
||||
<%= case @role do %>
|
||||
<% :administrator -> %>
|
||||
<b><%= gettext "Administrator" %></b>
|
||||
<% :moderator -> %>
|
||||
<b><%= gettext "Moderator" %></b>
|
||||
<% :user -> %>
|
||||
<b><%= gettext "User" %></b>
|
||||
<% end %>
|
1
lib/web/templates/email/admin/_role.text.eex
Normal file
1
lib/web/templates/email/admin/_role.text.eex
Normal file
@ -0,0 +1 @@
|
||||
<%= case @role do %><% :administrator -> %><%= gettext "Administrator" %><% :moderator -> %><%= gettext "Moderator" %><% :user -> %><%= gettext "User" %><% end %>
|
82
lib/web/templates/email/admin_user_confirmation.html.heex
Normal file
82
lib/web/templates/email/admin_user_confirmation.html.heex
Normal 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>
|
7
lib/web/templates/email/admin_user_confirmation.text.eex
Normal file
7
lib/web/templates/email/admin_user_confirmation.text.eex
Normal 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"} %>
|
@ -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>
|
@ -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"} %>
|
@ -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>
|
@ -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"} %>
|
78
lib/web/templates/email/admin_user_role_changed.html.heex
Normal file
78
lib/web/templates/email/admin_user_role_changed.html.heex
Normal 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>
|
8
lib/web/templates/email/admin_user_role_changed.text.eex
Normal file
8
lib/web/templates/email/admin_user_role_changed.text.eex
Normal 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"} %>
|
@ -1,5 +1,5 @@
|
||||
<%= gettext "Confirm new email" %>
|
||||
==
|
||||
<%= gettext "Hi there! It seems like you wanted to change the email address linked to your account on <b>%{instance}</b>. If you still wish to do so, please click the button below to confirm the change. You will then be able to log in to %{instance} with this new email address.", %{instance: @instance_name} %>
|
||||
<%= gettext "Hi there! It seems like you wanted to change the email address linked to your account on %{instance}. If you still wish to do so, please click the button below to confirm the change. You will then be able to log in to %{instance} with this new email address.", %{instance: @instance_name} %>
|
||||
<%= Routes.page_url(Mobilizon.Web.Endpoint, :user_email_validation, @token) %>
|
||||
<%= gettext "If you didn't trigger the change yourself, please ignore this message." %>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user