From 0d021021dfbf3c53789e700715c0fbf71f02ee7c Mon Sep 17 00:00:00 2001 From: Borislav Pantaleev Date: Thu, 17 Oct 2024 18:34:20 +0300 Subject: [PATCH] Add option for access token login (#58) * Fix SSO login flow, redirect is done after auth * Add accessToken login * Add confirmation for session destroy on accessToken logout * add translations, fix tests, minor renaming * update readme --- README.md | 1 + src/components/AdminLayout.tsx | 74 ++++- src/components/LoginFormBox.tsx | 58 ++++ src/i18n/de.ts | 8 + src/i18n/en.ts | 8 + src/i18n/fa.ts | 8 + src/i18n/fr.ts | 14 +- src/i18n/index.d.ts | 8 + src/i18n/it.ts | 396 +++++++++++++------------- src/i18n/ru.ts | 470 ++++++++++++++++--------------- src/i18n/zh.ts | 8 + src/index.tsx | 8 +- src/pages/LoginPage.tsx | 148 +++++----- src/synapse/authProvider.test.ts | 8 +- src/synapse/authProvider.ts | 34 ++- 15 files changed, 709 insertions(+), 542 deletions(-) create mode 100644 src/components/LoginFormBox.tsx diff --git a/README.md b/README.md index 054132b..4de6801 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ The following changes are already implemented: * [Provide options to delete media and redact events on user erase](https://github.com/etkecc/synapse-admin/pull/49) * [Authenticated Media support](https://github.com/etkecc/synapse-admin/pull/51) * [Better media preview/download](https://github.com/etkecc/synapse-admin/pull/53) +* [Login with access token](https://github.com/etkecc/synapse-admin/pull/58) _the list will be updated as new changes are added_ diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx index 90b6aba..36a5b7a 100644 --- a/src/components/AdminLayout.tsx +++ b/src/components/AdminLayout.tsx @@ -1,26 +1,70 @@ -import { Layout, Menu } from 'react-admin'; -import LiveHelpIcon from '@mui/icons-material/LiveHelp'; +import { AppBar, Confirm, Layout, Logout, Menu, useLogout, UserMenu } from "react-admin"; +import LiveHelpIcon from "@mui/icons-material/LiveHelp"; +import { LoginMethod } from "../pages/LoginPage"; +import { useState } from "react"; const DEFAULT_SUPPORT_LINK = "https://github.com/etkecc/synapse-admin/issues"; const supportLink = (): string => { - try { - new URL(localStorage.getItem("support_url") || ''); // Check if the URL is valid - return localStorage.getItem("support_url") || DEFAULT_SUPPORT_LINK; - } catch (e) { - return DEFAULT_SUPPORT_LINK; - } + try { + new URL(localStorage.getItem("support_url") || ""); // Check if the URL is valid + return localStorage.getItem("support_url") || DEFAULT_SUPPORT_LINK; + } catch (e) { + return DEFAULT_SUPPORT_LINK; + } }; +const AdminUserMenu = () => { + const [open, setOpen] = useState(false); + const logout = useLogout(); + const checkLoginType = (ev: React.MouseEvent) => { + const loginType: LoginMethod = (localStorage.getItem("login_type") || "credentials") as LoginMethod; + if (loginType === "accessToken") { + ev.stopPropagation(); + setOpen(true); + } + }; + + const handleConfirm = () => { + setOpen(false); + logout(); + }; + + const handleDialogClose = () => { + setOpen(false); + localStorage.removeItem("access_token"); + localStorage.removeItem("login_type"); + window.location.reload(); + }; + + return ( + +
+ +
+ +
+ ); +}; + +const AdminAppBar = () => } />; const AdminMenu = () => ( - - - } /> - + + + } /> + ); export const AdminLayout = ({ children }) => ( - - {children} - + + {children} + ); diff --git a/src/components/LoginFormBox.tsx b/src/components/LoginFormBox.tsx new file mode 100644 index 0000000..859f1c9 --- /dev/null +++ b/src/components/LoginFormBox.tsx @@ -0,0 +1,58 @@ +import { styled } from "@mui/material/styles"; +import { Box } from "@mui/material"; + +const LoginFormBox = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + minHeight: "calc(100vh - 1rem)", + alignItems: "center", + justifyContent: "flex-start", + background: "url(./images/floating-cogs.svg)", + backgroundColor: "#f9f9f9", + backgroundRepeat: "no-repeat", + backgroundSize: "cover", + + [`& .card`]: { + width: "30rem", + marginTop: "6rem", + marginBottom: "6rem", + }, + [`& .avatar`]: { + margin: "1rem", + display: "flex", + justifyContent: "center", + }, + [`& .icon`]: { + backgroundColor: theme.palette.grey[500], + }, + [`& .hint`]: { + marginTop: "1em", + marginBottom: "1em", + display: "flex", + justifyContent: "center", + color: theme.palette.grey[600], + }, + [`& .form`]: { + padding: "0 1rem 1rem 1rem", + }, + [`& .select`]: { + marginBottom: "2rem", + }, + [`& .actions`]: { + padding: "0 1rem 1rem 1rem", + }, + [`& .serverVersion`]: { + color: theme.palette.grey[500], + fontFamily: "Roboto, Helvetica, Arial, sans-serif", + marginLeft: "0.5rem", + }, + [`& .matrixVersions`]: { + color: theme.palette.grey[500], + fontFamily: "Roboto, Helvetica, Arial, sans-serif", + fontSize: "0.8rem", + marginBottom: "1rem", + marginLeft: "0.5rem", + }, +})); + +export default LoginFormBox; diff --git a/src/i18n/de.ts b/src/i18n/de.ts index aef4bb6..2331268 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -22,6 +22,14 @@ const de: SynapseTranslationMessages = { protocol_error: "Die URL muss mit 'http://' oder 'https://' beginnen", url_error: "Keine gültige Matrix Server URL", sso_sign_in: "Anmeldung mit SSO", + credentials: "Anmeldedaten", + access_token: "Zugriffstoken", + logout_acces_token_dialog: { + title: "Sie verwenden ein bestehendes Matrix-Zugriffstoken.", + content: "Möchten Sie diese Sitzung (die anderswo, z.B. in einem Matrix-Client, verwendet werden könnte) beenden oder sich nur vom Admin-Panel abmelden?", + confirm: "Sitzung beenden", + cancel: "Nur vom Admin-Panel abmelden", + }, }, users: { invalid_user_id: "Lokaler Anteil der Matrix Benutzer-ID ohne Homeserver.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 93ef69d..acc1146 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -14,6 +14,14 @@ const en: SynapseTranslationMessages = { protocol_error: "URL has to start with 'http://' or 'https://'", url_error: "Not a valid Matrix server URL", sso_sign_in: "Sign in with SSO", + credentials: "Credentials", + access_token: "Access token", + logout_acces_token_dialog: { + title: "You are using an existing Matrix access token.", + content: "Do you want to destroy this session (that could be used elsewhere, e.g. in a Matrix client) or just logout from the admin panel?", + confirm: "Destroy session", + cancel: "Just logout from admin panel", + }, }, users: { invalid_user_id: "Localpart of a Matrix user-id without homeserver.", diff --git a/src/i18n/fa.ts b/src/i18n/fa.ts index 7d79569..cbb83b4 100644 --- a/src/i18n/fa.ts +++ b/src/i18n/fa.ts @@ -13,6 +13,14 @@ const fa: SynapseTranslationMessages = { protocol_error: "URL باید با 'http://' یا 'https://' شروع شود", url_error: "آدرس وارد شده یک سرور معتبر نیست", sso_sign_in: "با SSO وارد شوید", + credentials: "اعتبارنامه", + access_token: "توکن دسترسی", + logout_acces_token_dialog: { + title: "شما در حال استفاده از یک نشانه دسترسی ماتریکس موجود هستید.", + content: "آیا می‌خواهید این جلسه (که می‌تواند در جای دیگر، مانند یک کلاینت ماتریکس استفاده شود) را نابود کنید یا فقط از پنل مدیریت خارج شوید؟", + confirm: "نابودی جلسه", + cancel: "فقط خروج از پنل مدیریت", + }, }, users: { invalid_user_id: "بخش محلی یک شناسه کاربری ماتریکس بدون سرور خانگی.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index c77422f..b34434a 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -13,6 +13,14 @@ const fr: SynapseTranslationMessages = { protocol_error: "L'URL doit commencer par « http:// » ou « https:// »", url_error: "L'URL du serveur Matrix n'est pas valide", sso_sign_in: "Se connecter avec l’authentification unique", + credentials: "Identifiants", + access_token: "Jeton d'accès", + logout_acces_token_dialog: { + title: "Vous utilisez un jeton d'accès Matrix existant.", + content: "Voulez-vous détruire cette session (qui pourrait être utilisée ailleurs, par exemple dans un client Matrix) ou simplement vous déconnecter du panneau d'administration?", + confirm: "Détruire la session", + cancel: "Se déconnecter simplement du panneau d'administration", + }, }, users: { invalid_user_id: "Partie locale d'un identifiant utilisateur Matrix sans le nom du serveur d’accueil.", @@ -201,9 +209,9 @@ const fr: SynapseTranslationMessages = { title: "Supprimer le salon", content: "Voulez-vous vraiment supprimer le salon ? Cette opération ne peut être annulée. Tous les messages et médias partagés du salon seront supprimés du serveur !", - fields: { - block: "Bloquer et empêcher les utilisateurs de rejoindre la salle", - }, + fields: { + block: "Bloquer et empêcher les utilisateurs de rejoindre la salle", + }, success: "Salle/s supprimées avec succès.", failure: "La/les salle/s n'ont pas pu être supprimées.", }, diff --git a/src/i18n/index.d.ts b/src/i18n/index.d.ts index 7d03720..6ddca8e 100644 --- a/src/i18n/index.d.ts +++ b/src/i18n/index.d.ts @@ -11,6 +11,14 @@ interface SynapseTranslationMessages extends TranslationMessages { protocol_error: string; url_error: string; sso_sign_in: string; + credentials: string; + access_token: string; + logout_acces_token_dialog: { + title: string; + content: string; + confirm: string; + cancel: string; + }; }; users: { invalid_user_id: string; diff --git a/src/i18n/it.ts b/src/i18n/it.ts index db188d9..f1f5b4f 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -13,6 +13,14 @@ const it: SynapseTranslationMessages = { protocol_error: "L'URL deve iniziare per 'http://' o 'https://'", url_error: "URL del server Matrix non valido", sso_sign_in: "Accedi con SSO", + credentials: "Credenziali", + access_token: "Token di accesso", + logout_acces_token_dialog: { + title: "Stai utilizzando un token di accesso Matrix esistente.", + content: "Vuoi distruggere questa sessione (che potrebbe essere utilizzata altrove, ad esempio in un client Matrix) o semplicemente disconnetterti dal pannello di amministrazione?", + confirm: "Distruggi sessione", + cancel: "Disconnetti solo dal pannello di amministrazione", + }, }, users: { invalid_user_id: "ID utente non valido su questo homeserver.", @@ -174,211 +182,211 @@ const it: SynapseTranslationMessages = { }, helper: { /* forward_extremities: - "Forward extremities are the leaf events at the end of a Directed acyclic graph (DAG) in a room, aka events that have no children. The more exist in a room, the more state resolution that Synapse needs to perform (hint: it's an expensive operation). While Synapse has code to prevent too many of these existing at one time in a room, bugs can sometimes make them crop up again. If a room has >10 forward extremities, it's worth checking which room is the culprit and potentially removing them using the SQL queries mentioned in #1760.", */ - }, - enums: { + "Forward extremities are the leaf events at the end of a Directed acyclic graph (DAG) in a room, aka events that have no children. The more exist in a room, the more state resolution that Synapse needs to perform (hint: it's an expensive operation). While Synapse has code to prevent too many of these existing at one time in a room, bugs can sometimes make them crop up again. If a room has >10 forward extremities, it's worth checking which room is the culprit and potentially removing them using the SQL queries mentioned in #1760.", */ + }, + enums: { join_rules: { - public: "Pubblica", - knock: "Bussa", - invite: "Invita", - private: "Privata", - }, - guest_access: { - can_join: "Gli utenti ospiti possono entrare", - forbidden: "Gli utenti ospiti non possono entrare", - }, - history_visibility: { - invited: "Dall'invito", - joined: "Dall'entrata", - shared: "Dalla condivisione", - world_readable: "Chiunque", - }, - unencrypted: "Non criptata", + public: "Pubblica", + knock: "Bussa", + invite: "Invita", + private: "Privata", }, - action: { - erase: { - title: "Cancella stanza", - content: - "Sei sicuro di voler eliminare questa stanza? Questa azione è definitiva. Tutti i messaggi e i media condivisi in questa stanza verranno eliminati dal server!", - }, + guest_access: { + can_join: "Gli utenti ospiti possono entrare", + forbidden: "Gli utenti ospiti non possono entrare", }, + history_visibility: { + invited: "Dall'invito", + joined: "Dall'entrata", + shared: "Dalla condivisione", + world_readable: "Chiunque", + }, + unencrypted: "Non criptata", }, - reports: { - name: "Evento segnalato |||| Eventi segnalati", - fields: { - id: "ID", - received_ts: "Orario del report", - user_id: "richiedente", - name: "nome della stanza", - score: "punteggio", - reason: "ragione", - event_id: "ID dell'evento", - event_json: { - origin: "server di origine", - origin_server_ts: "ora dell'invio", - type: "tipo di evento", - content: { - msgtype: "tipo di contenuto", - body: "contenuto", - format: "formato", - formatted_body: "contenuto formattato", - algorithm: "algoritmo", - }, - }, - }, - }, - connections: { - name: "Connessioni", - fields: { - last_seen: "Data", - ip: "Indirizzo IP", - user_agent: "agente utente", - }, - }, - devices: { - name: "Dispositivo |||| Dispositivi", - fields: { - device_id: "ID del dispositivo", - display_name: "Nome del dispositivo", - last_seen_ts: "Timestamp", - last_seen_ip: "Indirizzo IP", - }, - action: { - erase: { - title: "Rimozione del dispositivo %{id}", - content: 'Sei sicuro di voler rimuovere il dispositivo "%{name}"?', - success: "Dispositivo rimosso con successo.", - failure: "C'è stato un errore.", - }, - }, - }, - users_media: { - name: "Media", - fields: { - media_id: "ID del media", - media_length: "Peso del file (in Byte)", - media_type: "Tipo", - upload_name: "Nome del file", - quarantined_by: "In quarantena da", - safe_from_quarantine: "Protetto dalla quarantena", - created_ts: "Creato", - last_access_ts: "Ultimo accesso", - }, - }, - protect_media: { - action: { - create: "Non protetto, proteggi", - delete: "Protetto, rimuovi protezione", - none: "In quarantena", - send_success: "Stato della protezione cambiato con successo.", - send_failure: "C'è stato un errore.", - }, - }, - quarantine_media: { - action: { - name: "Quarantina", - create: "Aggiungi alla quarantena", - delete: "In quarantena, rimuovi dalla quarantena", - none: "Protetto dalla quarantena", - send_success: "Stato della quarantena cambiato con successo.", - send_failure: "C'è stato un errore.", - }, - }, - pushers: { - name: "Pusher |||| Pusher", - fields: { - app: "App", - app_display_name: "Nome dell'app", - app_id: "ID dell'app", - device_display_name: "Nome del dispositivo", - kind: "Tipo", - lang: "Lingua", - profile_tag: "Tag del profilo", - pushkey: "Pushkey", - data: { url: "URL" }, - }, - }, - servernotices: { - name: "Avvisi del server", - send: "Invia avvisi", - fields: { - body: "Messaggio", - }, - action: { - send: "Invia nota", - send_success: "Avviso inviato con successo.", - send_failure: "C'è stato un errore.", - }, - helper: { - send: 'Invia un avviso dal server agli utenti selezionati. La feature "Avvisi del server" è stata attivata sul server.', - }, - }, - user_media_statistics: { - name: "Media degli utenti", - fields: { - media_count: "Numero media", - media_length: "Lunghezza media", - }, - }, - forward_extremities: { - name: "Invia estremità", - fields: { - id: "Event ID", - received_ts: "Timestamp", - depth: "Profondità", - state_group: "State group", - }, - }, - room_state: { - name: "Eventi di stato", - fields: { - type: "Tipo", - content: "Contenuto", - origin_server_ts: "Ora dell'invio", - sender: "Mittente", - }, - }, - room_directory: { - name: "Elenco delle stanze", - fields: { - world_readable: "gli utenti ospite possono vedere senza entrare", - guest_can_join: "gli utenti ospite possono entrare", - }, - action: { - title: "Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco", + action: { + erase: { + title: "Cancella stanza", content: - "Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?", - erase: "Rimuovi dall'elenco", - create: "Crea", - send_success: "Stanza creata con successo.", - send_failure: "C'è stato un errore.", + "Sei sicuro di voler eliminare questa stanza? Questa azione è definitiva. Tutti i messaggi e i media condivisi in questa stanza verranno eliminati dal server!", }, }, - destinations: { - name: "Federazione", - fields: { - destination: "Destinazione", - failure_ts: "Timestamp dell'errore", - retry_last_ts: "Tentativo ultimo timestamp", - retry_interval: "Intervallo dei tentativi", - last_successful_stream_ordering: "Ultimo flusso riuscito con successo", - stream_ordering: "Flusso", + }, + reports: { + name: "Evento segnalato |||| Eventi segnalati", + fields: { + id: "ID", + received_ts: "Orario del report", + user_id: "richiedente", + name: "nome della stanza", + score: "punteggio", + reason: "ragione", + event_id: "ID dell'evento", + event_json: { + origin: "server di origine", + origin_server_ts: "ora dell'invio", + type: "tipo di evento", + content: { + msgtype: "tipo di contenuto", + body: "contenuto", + format: "formato", + formatted_body: "contenuto formattato", + algorithm: "algoritmo", + }, }, - action: { reconnect: "Riconnetti" }, }, - registration_tokens: { - name: "Token di registrazione", - fields: { - token: "Token", - valid: "Token valido", - uses_allowed: "Usi permessi", - pending: "In attesa", - completed: "Completato", - expiry_time: "Data della scadenza", - length: "Lunghezza", + }, + connections: { + name: "Connessioni", + fields: { + last_seen: "Data", + ip: "Indirizzo IP", + user_agent: "agente utente", + }, + }, + devices: { + name: "Dispositivo |||| Dispositivi", + fields: { + device_id: "ID del dispositivo", + display_name: "Nome del dispositivo", + last_seen_ts: "Timestamp", + last_seen_ip: "Indirizzo IP", + }, + action: { + erase: { + title: "Rimozione del dispositivo %{id}", + content: 'Sei sicuro di voler rimuovere il dispositivo "%{name}"?', + success: "Dispositivo rimosso con successo.", + failure: "C'è stato un errore.", }, - helper: { length: "Lunghezza del token se non viene dato alcun token." }, }, }, + users_media: { + name: "Media", + fields: { + media_id: "ID del media", + media_length: "Peso del file (in Byte)", + media_type: "Tipo", + upload_name: "Nome del file", + quarantined_by: "In quarantena da", + safe_from_quarantine: "Protetto dalla quarantena", + created_ts: "Creato", + last_access_ts: "Ultimo accesso", + }, + }, + protect_media: { + action: { + create: "Non protetto, proteggi", + delete: "Protetto, rimuovi protezione", + none: "In quarantena", + send_success: "Stato della protezione cambiato con successo.", + send_failure: "C'è stato un errore.", + }, + }, + quarantine_media: { + action: { + name: "Quarantina", + create: "Aggiungi alla quarantena", + delete: "In quarantena, rimuovi dalla quarantena", + none: "Protetto dalla quarantena", + send_success: "Stato della quarantena cambiato con successo.", + send_failure: "C'è stato un errore.", + }, + }, + pushers: { + name: "Pusher |||| Pusher", + fields: { + app: "App", + app_display_name: "Nome dell'app", + app_id: "ID dell'app", + device_display_name: "Nome del dispositivo", + kind: "Tipo", + lang: "Lingua", + profile_tag: "Tag del profilo", + pushkey: "Pushkey", + data: { url: "URL" }, + }, + }, + servernotices: { + name: "Avvisi del server", + send: "Invia avvisi", + fields: { + body: "Messaggio", + }, + action: { + send: "Invia nota", + send_success: "Avviso inviato con successo.", + send_failure: "C'è stato un errore.", + }, + helper: { + send: 'Invia un avviso dal server agli utenti selezionati. La feature "Avvisi del server" è stata attivata sul server.', + }, + }, + user_media_statistics: { + name: "Media degli utenti", + fields: { + media_count: "Numero media", + media_length: "Lunghezza media", + }, + }, + forward_extremities: { + name: "Invia estremità", + fields: { + id: "Event ID", + received_ts: "Timestamp", + depth: "Profondità", + state_group: "State group", + }, + }, + room_state: { + name: "Eventi di stato", + fields: { + type: "Tipo", + content: "Contenuto", + origin_server_ts: "Ora dell'invio", + sender: "Mittente", + }, + }, + room_directory: { + name: "Elenco delle stanze", + fields: { + world_readable: "gli utenti ospite possono vedere senza entrare", + guest_can_join: "gli utenti ospite possono entrare", + }, + action: { + title: "Cancella stanza dall'elenco |||| Cancella %{smart_count} stanze dall'elenco", + content: + "Sei sicuro di voler rimuovere questa stanza dall'elenco? |||| Sei sicuro di voler rimuovere %{smart_count} stanze dall'elenco?", + erase: "Rimuovi dall'elenco", + create: "Crea", + send_success: "Stanza creata con successo.", + send_failure: "C'è stato un errore.", + }, + }, + destinations: { + name: "Federazione", + fields: { + destination: "Destinazione", + failure_ts: "Timestamp dell'errore", + retry_last_ts: "Tentativo ultimo timestamp", + retry_interval: "Intervallo dei tentativi", + last_successful_stream_ordering: "Ultimo flusso riuscito con successo", + stream_ordering: "Flusso", + }, + action: { reconnect: "Riconnetti" }, + }, + registration_tokens: { + name: "Token di registrazione", + fields: { + token: "Token", + valid: "Token valido", + uses_allowed: "Usi permessi", + pending: "In attesa", + completed: "Completato", + expiry_time: "Data della scadenza", + length: "Lunghezza", + }, + helper: { length: "Lunghezza del token se non viene dato alcun token." }, + }, + }, }; export default it; diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 2db8278..f2492a9 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -22,6 +22,14 @@ const ru: SynapseTranslationMessages = { protocol_error: "Адрес должен начинаться с 'http://' или 'https://'", url_error: "Неверный адрес сервера Matrix", sso_sign_in: "Вход через SSO", + credentials: "Учетные данные", + access_token: "Токен доступа", + logout_acces_token_dialog: { + title: "Вы используете существующий токен доступа Matrix.", + content: "Вы хотите завершить эту сессию (которая может быть использована в другом месте, например, в клиенте Matrix) или просто выйти из панели администрирования?", + confirm: "Завершить сессию", + cancel: "Просто выйти из панели администрирования", + }, }, users: { invalid_user_id: "Локальная часть ID пользователя Matrix без адреса домашнего сервера.", @@ -87,7 +95,7 @@ const ru: SynapseTranslationMessages = { header: "Загрузить CSV файл", explanation: "Здесь вы можете загрузить файл со значениями, разделёнными запятыми, которые будут использованы для создания или обновления данных пользователей. \ - В файле должны быть поля 'id' и 'displayname'. Вы можете скачать и изменить файл-образец отсюда: ", + В файле должны быть поля 'id' и 'displayname'. Вы можете скачать и изменить файл-образец отсюда: ", }, startImport: { simulate_only: "Только симулировать", @@ -122,10 +130,10 @@ const ru: SynapseTranslationMessages = { helper: { send: "Это API удаляет локальные файлы с вашего собственного сервера, включая локальные миниатюры и копии скачанных файлов. \ Данный API не затрагивает файлы, загруженные во внешние хранилища.", - }, - }, - resources: { - users: { + }, + }, + resources: { + users: { name: "Пользователь |||| Пользователи", email: "Почта", msisdn: "Телефон", @@ -169,258 +177,258 @@ const ru: SynapseTranslationMessages = { delete_media: "Удаление всех медиафайлов, загруженных пользователем (-ами)", redact_events: "Удаление всех событий, отправленных пользователем (-ами)", }, - }, - rooms: { - name: "Комната |||| Комнаты", - fields: { - room_id: "ID комнаты", - name: "Название", - canonical_alias: "Псевдоним", - joined_members: "Участники", - joined_local_members: "Локальные участники", - joined_local_devices: "Локальные устройства", - state_events: "События состояния / Сложность", - version: "Версия", - is_encrypted: "Зашифровано", - encryption: "Шифрование", - federatable: "Федерация", - public: "Отображается в каталоге комнат", - creator: "Создатель", - join_rules: "Правила входа", - guest_access: "Гостевой доступ", - history_visibility: "Видимость истории", - topic: "Тема", - avatar: "Аватар", }, - helper: { - forward_extremities: - "Оконечности — это события-листья в конце ориентированного ациклического графа (DAG) в комнате, т.е. события без дочерних элементов. \ + rooms: { + name: "Комната |||| Комнаты", + fields: { + room_id: "ID комнаты", + name: "Название", + canonical_alias: "Псевдоним", + joined_members: "Участники", + joined_local_members: "Локальные участники", + joined_local_devices: "Локальные устройства", + state_events: "События состояния / Сложность", + version: "Версия", + is_encrypted: "Зашифровано", + encryption: "Шифрование", + federatable: "Федерация", + public: "Отображается в каталоге комнат", + creator: "Создатель", + join_rules: "Правила входа", + guest_access: "Гостевой доступ", + history_visibility: "Видимость истории", + topic: "Тема", + avatar: "Аватар", + }, + helper: { + forward_extremities: + "Оконечности — это события-листья в конце ориентированного ациклического графа (DAG) в комнате, т.е. события без дочерних элементов. \ Чем больше их в комнате, тем больше Synapse работает над разрешением состояния (это дорогостоящая операция). \ Хотя Synapse старается не допускать существования слишком большого числа таких событий в комнате, из-за ошибок они иногда снова появляются. \ Если в комнате >10 оконечностей, стоит найти комнату-виновника и попробовать удалить их с помощью SQL-запросов из #1760.", - }, - enums: { - join_rules: { - public: "Для всех", - knock: "Надо постучать", - invite: "По приглашению", - private: "Приватная", }, - guest_access: { - can_join: "Гости могут войти", - forbidden: "Гости не могут войти", - }, - history_visibility: { - invited: "С момента приглашения", - joined: "С момента входа", - shared: "С момента открытия доступа", - world_readable: "Для всех", - }, - unencrypted: "Без шифрования", - }, - action: { - erase: { - title: "Удалить комнату", - content: - "Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!", - fields: { - block: "Заблокировать и запретить пользователям присоединяться к комнате", + enums: { + join_rules: { + public: "Для всех", + knock: "Надо постучать", + invite: "По приглашению", + private: "Приватная", + }, + guest_access: { + can_join: "Гости могут войти", + forbidden: "Гости не могут войти", + }, + history_visibility: { + invited: "С момента приглашения", + joined: "С момента входа", + shared: "С момента открытия доступа", + world_readable: "Для всех", + }, + unencrypted: "Без шифрования", + }, + action: { + erase: { + title: "Удалить комнату", + content: + "Действительно удалить эту комнату? Это действие будет невозможно отменить. Все сообщения и файлы в комнате будут удалены с сервера!", + fields: { + block: "Заблокировать и запретить пользователям присоединяться к комнате", + }, + success: "Комната/ы успешно удалены", + failure: "Комната/ы не могут быть удалены.", }, - success: "Комната/ы успешно удалены", - failure: "Комната/ы не могут быть удалены.", }, }, - }, - reports: { - name: "Жалоба |||| Жалобы", - fields: { - id: "ID", - received_ts: "Дата и время жалобы", - user_id: "Автор жалобы", - name: "Название комнаты", - score: "Баллы", - reason: "Причина", - event_id: "ID события", - event_json: { - origin: "Исходнный сервер", - origin_server_ts: "Дата и время отправки", - type: "Тип события", - content: { - msgtype: "Тип содержимого", - body: "Содержимое", - format: "Формат", - formatted_body: "Форматированное содержимое", - algorithm: "Алгоритм", - url: "Ссылка", - info: { - mimetype: "Тип", + reports: { + name: "Жалоба |||| Жалобы", + fields: { + id: "ID", + received_ts: "Дата и время жалобы", + user_id: "Автор жалобы", + name: "Название комнаты", + score: "Баллы", + reason: "Причина", + event_id: "ID события", + event_json: { + origin: "Исходнный сервер", + origin_server_ts: "Дата и время отправки", + type: "Тип события", + content: { + msgtype: "Тип содержимого", + body: "Содержимое", + format: "Формат", + formatted_body: "Форматированное содержимое", + algorithm: "Алгоритм", + url: "Ссылка", + info: { + mimetype: "Тип", + }, }, }, }, - }, - action: { - erase: { - title: "Удалить жалобу", - content: "Действительно удалить жалобу? Это действие будет невозможно отменить.", + action: { + erase: { + title: "Удалить жалобу", + content: "Действительно удалить жалобу? Это действие будет невозможно отменить.", + }, }, }, - }, - connections: { - name: "Подключения", - fields: { - last_seen: "Дата", - ip: "IP адрес", - user_agent: "Юзер-агент", - }, - }, - devices: { - name: "Устройство |||| Устройства", - fields: { - device_id: "ID устройства", - display_name: "Название", - last_seen_ts: "Дата и время", - last_seen_ip: "IP адрес", - }, - action: { - erase: { - title: "Удаление %{id}", - content: 'Действительно удалить устройство "%{name}"?', - success: "Устройство успешно удалено.", - failure: "Произошла ошибка.", + connections: { + name: "Подключения", + fields: { + last_seen: "Дата", + ip: "IP адрес", + user_agent: "Юзер-агент", }, }, - }, - users_media: { - name: "Файлы", - fields: { - media_id: "ID файла", - media_length: "Размер файла (в байтах)", - media_type: "Тип", - upload_name: "Имя файла", - quarantined_by: "На карантине", - safe_from_quarantine: "Защитить от карантина", - created_ts: "Создано", - last_access_ts: "Последний доступ", + devices: { + name: "Устройство |||| Устройства", + fields: { + device_id: "ID устройства", + display_name: "Название", + last_seen_ts: "Дата и время", + last_seen_ip: "IP адрес", + }, + action: { + erase: { + title: "Удаление %{id}", + content: 'Действительно удалить устройство "%{name}"?', + success: "Устройство успешно удалено.", + failure: "Произошла ошибка.", + }, + }, }, - action: { - open: "Открыть файл в новом окне", + users_media: { + name: "Файлы", + fields: { + media_id: "ID файла", + media_length: "Размер файла (в байтах)", + media_type: "Тип", + upload_name: "Имя файла", + quarantined_by: "На карантине", + safe_from_quarantine: "Защитить от карантина", + created_ts: "Создано", + last_access_ts: "Последний доступ", + }, + action: { + open: "Открыть файл в новом окне", + }, }, - }, - protect_media: { - action: { - create: "Не защищён, установить защиту", - delete: "Защищён, снять защиту", - none: "На карантине", - send_success: "Статус защиты успешно изменён.", - send_failure: "Произошла ошибка.", + protect_media: { + action: { + create: "Не защищён, установить защиту", + delete: "Защищён, снять защиту", + none: "На карантине", + send_success: "Статус защиты успешно изменён.", + send_failure: "Произошла ошибка.", + }, }, - }, - quarantine_media: { - action: { - name: "Карантин", - create: "Поместить на карантин", - delete: "На карантине, снять карантин", - none: "Защищено от карантина", - send_success: "Статус карантина успешно изменён.", - send_failure: "Произошла ошибка.", + quarantine_media: { + action: { + name: "Карантин", + create: "Поместить на карантин", + delete: "На карантине, снять карантин", + none: "Защищено от карантина", + send_success: "Статус карантина успешно изменён.", + send_failure: "Произошла ошибка.", + }, }, - }, - pushers: { - name: "Пушер |||| Пушеры", - fields: { - app: "Приложение", - app_display_name: "Название приложения", - app_id: "ID приложения", - device_display_name: "Название устройства", - kind: "Вид", - lang: "Язык", - profile_tag: "Тег профиля", - pushkey: "Ключ", - data: { url: "URL" }, + pushers: { + name: "Пушер |||| Пушеры", + fields: { + app: "Приложение", + app_display_name: "Название приложения", + app_id: "ID приложения", + device_display_name: "Название устройства", + kind: "Вид", + lang: "Язык", + profile_tag: "Тег профиля", + pushkey: "Ключ", + data: { url: "URL" }, + }, }, - }, - servernotices: { - name: "Серверные уведомления", - send: "Отправить серверные уведомления", - fields: { - body: "Сообщение", + servernotices: { + name: "Серверные уведомления", + send: "Отправить серверные уведомления", + fields: { + body: "Сообщение", + }, + action: { + send: "Отправить", + send_success: "Серверное уведомление успешно отправлено.", + send_failure: "Произошла ошибка.", + }, + helper: { + send: 'Отправить серверное уведомление выбранным пользователям. На сервере должна быть активна функция "Server Notices".', + }, }, - action: { - send: "Отправить", - send_success: "Серверное уведомление успешно отправлено.", - send_failure: "Произошла ошибка.", + user_media_statistics: { + name: "Файлы пользователей", + fields: { + media_count: "Количество файлов", + media_length: "Размер файлов", + }, }, - helper: { - send: 'Отправить серверное уведомление выбранным пользователям. На сервере должна быть активна функция "Server Notices".', + forward_extremities: { + name: "Оконечности", + fields: { + id: "ID события", + received_ts: "Дата и время", + depth: "Глубина", + state_group: "Группа состояния", + }, }, - }, - user_media_statistics: { - name: "Файлы пользователей", - fields: { - media_count: "Количество файлов", - media_length: "Размер файлов", + room_state: { + name: "События состояния", + fields: { + type: "Тип", + content: "Содержимое", + origin_server_ts: "Дата отправки", + sender: "Отправитель", + }, }, - }, - forward_extremities: { - name: "Оконечности", - fields: { - id: "ID события", - received_ts: "Дата и время", - depth: "Глубина", - state_group: "Группа состояния", + room_directory: { + name: "Каталог комнат", + fields: { + world_readable: "Гости могут просматривать без входа", + guest_can_join: "Гости могут войти", + }, + action: { + title: + "Удалить комнату из каталога |||| Удалить %{smart_count} комнаты из каталога |||| Удалить %{smart_count} комнат из каталога", + content: + "Действительно удалить комнату из каталога? |||| Действительно удалить %{smart_count} комнаты из каталога? |||| Действительно удалить %{smart_count} комнат из каталога?", + erase: "Удалить из каталога комнат", + create: "Опубликовать в каталоге комнат", + send_success: "Комната успешно опубликована.", + send_failure: "Произошла ошибка.", + }, }, - }, - room_state: { - name: "События состояния", - fields: { - type: "Тип", - content: "Содержимое", - origin_server_ts: "Дата отправки", - sender: "Отправитель", + destinations: { + name: "Федерация", + fields: { + destination: "Назначение", + failure_ts: "Дата и время ошибки", + retry_last_ts: "Дата и время последней попытки", + retry_interval: "Интервал между попытками", + last_successful_stream_ordering: "Последний успешный поток", + stream_ordering: "Поток", + }, + action: { reconnect: "Переподключиться" }, }, - }, - room_directory: { - name: "Каталог комнат", - fields: { - world_readable: "Гости могут просматривать без входа", - guest_can_join: "Гости могут войти", + registration_tokens: { + name: "Токены регистрации", + fields: { + token: "Токен", + valid: "Рабочий токен", + uses_allowed: "Количество использований", + pending: "Ожидает", + completed: "Завершено", + expiry_time: "Дата окончания", + length: "Длина", + }, + helper: { length: "Длина токена, если токен не задан." }, }, - action: { - title: - "Удалить комнату из каталога |||| Удалить %{smart_count} комнаты из каталога |||| Удалить %{smart_count} комнат из каталога", - content: - "Действительно удалить комнату из каталога? |||| Действительно удалить %{smart_count} комнаты из каталога? |||| Действительно удалить %{smart_count} комнат из каталога?", - erase: "Удалить из каталога комнат", - create: "Опубликовать в каталоге комнат", - send_success: "Комната успешно опубликована.", - send_failure: "Произошла ошибка.", - }, - }, - destinations: { - name: "Федерация", - fields: { - destination: "Назначение", - failure_ts: "Дата и время ошибки", - retry_last_ts: "Дата и время последней попытки", - retry_interval: "Интервал между попытками", - last_successful_stream_ordering: "Последний успешный поток", - stream_ordering: "Поток", - }, - action: { reconnect: "Переподключиться" }, - }, - registration_tokens: { - name: "Токены регистрации", - fields: { - token: "Токен", - valid: "Рабочий токен", - uses_allowed: "Количество использований", - pending: "Ожидает", - completed: "Завершено", - expiry_time: "Дата окончания", - length: "Длина", - }, - helper: { length: "Длина токена, если токен не задан." }, - }, - }, +}, }; export default ru; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 0064a15..0a3113c 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -21,6 +21,14 @@ const zh: SynapseTranslationMessages = { protocol_error: "URL 需要以'http://'或'https://'作为起始", url_error: "不是一个有效的 Matrix 服务器地址", sso_sign_in: "使用 SSO 登录", + credentials: "凭证", + access_token: "访问令牌", + logout_acces_token_dialog: { + title: "您正在使用现有的 Matrix 访问令牌。", + content: "您想销毁此会话(可能在其他地方使用,例如在 Matrix 客户端中)还是仅从管理面板退出?", + confirm: "销毁会话", + cancel: "仅从管理面板退出", + }, }, users: { invalid_user_id: "必须要是一个有效的 Matrix 用户 ID ,例如 @user_id:homeserver", diff --git a/src/index.tsx b/src/index.tsx index 21621c9..770d1d9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,8 +9,12 @@ import storage from "./storage"; fetch("config.json") .then(res => res.json()) .then(props => { - storage.setItem("as_managed_users", JSON.stringify(props.asManagedUsers)); - storage.setItem("support_url", props.supportURL); + if (props.asManagedUsers) { + storage.setItem("as_managed_users", JSON.stringify(props.asManagedUsers)); + } + if (props.supportURL) { + storage.setItem("support_url", props.supportURL); + } return createRoot(document.getElementById("root")).render( diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index e0fbb09..7d2e8d3 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,8 +1,7 @@ import { useState, useEffect } from "react"; import LockIcon from "@mui/icons-material/Lock"; -import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Typography } from "@mui/material"; -import { styled } from "@mui/material/styles"; +import { Avatar, Box, Button, Card, CardActions, CircularProgress, MenuItem, Select, Tab, Tabs, Typography } from "@mui/material"; import { Form, FormDataConsumer, @@ -17,7 +16,7 @@ import { useLocales, } from "react-admin"; import { useFormContext } from "react-hook-form"; - +import LoginFormBox from "../components/LoginFormBox"; import { useAppContext } from "../AppContext"; import { getServerVersion, @@ -29,66 +28,18 @@ import { } from "../synapse/synapse"; import storage from "../storage"; -const FormBox = styled(Box)(({ theme }) => ({ - display: "flex", - flexDirection: "column", - minHeight: "calc(100vh - 1rem)", - alignItems: "center", - justifyContent: "flex-start", - background: "url(./images/floating-cogs.svg)", - backgroundColor: "#f9f9f9", - backgroundRepeat: "no-repeat", - backgroundSize: "cover", - - [`& .card`]: { - width: "30rem", - marginTop: "6rem", - marginBottom: "6rem", - }, - [`& .avatar`]: { - margin: "1rem", - display: "flex", - justifyContent: "center", - }, - [`& .icon`]: { - backgroundColor: theme.palette.grey[500], - }, - [`& .hint`]: { - marginTop: "1em", - marginBottom: "1em", - display: "flex", - justifyContent: "center", - color: theme.palette.grey[600], - }, - [`& .form`]: { - padding: "0 1rem 1rem 1rem", - }, - [`& .select`]: { - marginBottom: "2rem", - }, - [`& .actions`]: { - padding: "0 1rem 1rem 1rem", - }, - [`& .serverVersion`]: { - color: theme.palette.grey[500], - fontFamily: "Roboto, Helvetica, Arial, sans-serif", - marginLeft: "0.5rem", - }, - [`& .matrixVersions`]: { - color: theme.palette.grey[500], - fontFamily: "Roboto, Helvetica, Arial, sans-serif", - fontSize: "0.8rem", - marginBottom: "1rem", - marginLeft: "0.5rem", - }, -})); +export type LoginMethod = "credentials" | "accessToken"; const LoginPage = () => { const login = useLogin(); const notify = useNotify(); const { restrictBaseUrl } = useAppContext(); const allowSingleBaseUrl = typeof restrictBaseUrl === "string"; - const allowMultipleBaseUrls = (Array.isArray(restrictBaseUrl) && restrictBaseUrl.length > 0 && restrictBaseUrl[0] !== "" && restrictBaseUrl[0] !== null); + const allowMultipleBaseUrls = + Array.isArray(restrictBaseUrl) && + restrictBaseUrl.length > 0 && + restrictBaseUrl[0] !== "" && + restrictBaseUrl[0] !== null; const allowAnyBaseUrl = !(allowSingleBaseUrl || allowMultipleBaseUrls); const [loading, setLoading] = useState(false); const [supportPassAuth, setSupportPassAuth] = useState(true); @@ -98,8 +49,13 @@ const LoginPage = () => { const base_url = allowSingleBaseUrl ? restrictBaseUrl : storage.getItem("base_url"); const [ssoBaseUrl, setSSOBaseUrl] = useState(""); const loginToken = /\?loginToken=([a-zA-Z0-9_-]+)/.exec(window.location.href); + const [loginMethod, setLoginMethod] = useState("credentials"); + + useEffect(() => { + if (!loginToken) { + return; + } - if (loginToken) { const ssoToken = loginToken[1]; console.log("SSO token is", ssoToken); // Prevent further requests @@ -127,7 +83,7 @@ const LoginPage = () => { console.error(error); }); } - } + }, [loginToken]); const validateBaseUrl = value => { if (!value.match(/^(http|https):\/\//)) { @@ -213,29 +169,53 @@ const LoginPage = () => { return ( <> - - - - - - + setLoginMethod(newValue as LoginMethod)} + indicatorColor="primary" + textColor="primary" + centered + > + + + + {loginMethod === "credentials" ? ( + <> + + + + + + + + ) : ( + + + + )} { return (
- + {loading ? ( @@ -312,7 +292,7 @@ const LoginPage = () => { - + ); diff --git a/src/synapse/authProvider.test.ts b/src/synapse/authProvider.test.ts index 3dddc74..d47894f 100644 --- a/src/synapse/authProvider.test.ts +++ b/src/synapse/authProvider.test.ts @@ -23,13 +23,13 @@ describe("authProvider", () => { }) ); - const ret: undefined = await authProvider.login({ + const ret = await authProvider.login({ base_url: "http://example.com", username: "@user:example.com", password: "secret", }); - expect(ret).toBe(undefined); + expect(ret).toEqual({redirectTo: "/"}); expect(fetch).toBeCalledWith("http://example.com/_matrix/client/r0/login", { body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.password","identifier":{"type":"m.id.user","user":"@user:example.com"},"password":"secret"}', headers: new Headers({ @@ -55,12 +55,12 @@ describe("authProvider", () => { }) ); - const ret: undefined = await authProvider.login({ + const ret = await authProvider.login({ base_url: "https://example.com/", loginToken: "login_token", }); - expect(ret).toBe(undefined); + expect(ret).toEqual({redirectTo: "/"}); expect(fetch).toHaveBeenCalledWith("https://example.com/_matrix/client/r0/login", { body: '{"device_id":null,"initial_device_display_name":"Synapse Admin","type":"m.login.token","token":"login_token"}', headers: new Headers({ diff --git a/src/synapse/authProvider.ts b/src/synapse/authProvider.ts index 19cb4ce..4ededae 100644 --- a/src/synapse/authProvider.ts +++ b/src/synapse/authProvider.ts @@ -10,14 +10,16 @@ const authProvider: AuthProvider = { username, password, loginToken, + accessToken, }: { base_url: string; username: string; password: string; loginToken: string; + accessToken: string; }) => { console.log("login "); - const options: Options = { + let options: Options = { method: "POST", body: JSON.stringify( Object.assign( @@ -55,11 +57,30 @@ const authProvider: AuthProvider = { storage.setItem("base_url", base_url); const decoded_base_url = window.decodeURIComponent(base_url); - const login_api_url = decoded_base_url + "/_matrix/client/r0/login"; + let login_api_url = decoded_base_url + (accessToken ? "/_matrix/client/v3/account/whoami" : "/_matrix/client/r0/login"); let response; + try { + if (accessToken) { + // this a login with an already obtained access token, let's just validate it + options = { + headers: new Headers({ + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }), + }; + } + response = await fetchUtils.fetchJson(login_api_url, options); + const json = response.json; + storage.setItem("home_server", accessToken ? base_url : json.home_server); + storage.setItem("user_id", json.user_id); + storage.setItem("access_token", accessToken ? accessToken : json.access_token); + storage.setItem("device_id", json.device_id); + storage.setItem("login_type", accessToken ? "accessToken" : "credentials"); + + return Promise.resolve({redirectTo: "/"}); } catch(err) { const error = err as HttpError; const errorStatus = error.status; @@ -71,14 +92,8 @@ const authProvider: AuthProvider = { errMsg, errorStatus, ) - ); + ); } - - const json = response.json; - storage.setItem("home_server", json.home_server); - storage.setItem("user_id", json.user_id); - storage.setItem("access_token", json.access_token); - storage.setItem("device_id", json.device_id); }, // called when the user clicks on the logout button logout: async () => { @@ -102,6 +117,7 @@ const authProvider: AuthProvider = { console.log("Error logging out", err); } finally { storage.removeItem("access_token"); + storage.removeItem("login_type"); } } },