diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index 908808af8..632697e58 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -38,26 +38,29 @@ core event as for one of your own. ## Core events (`@vitnode/core`) -Fourteen names, all declared in `VitNodeEvents` in +Seventeen names, all declared in `VitNodeEvents` in `packages/vitnode/src/api/models/events.ts`. Every one of them fires **after** the write it describes has committed. -| Event | Payload | Fires when | -| ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `user.created` | `{ userId, email, name, emailVerified }` | A user row is inserted - public sign-up, AdminCP creation, or SSO sign-up | -| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP, or a member saves their own profile or time zone in **Settings → Overview** | -| `user.deleted` | `{ userId, email }` | Never - the name is declared for plugins, core has no deletion flow | -| `user.sso.linked` | `{ userId, email, providerId }` | An SSO identity is linked to an existing account - at login with its password, or from **Settings → Connected accounts** | -| `user.sso.unlinked` | `{ userId, providerId }` | A member disconnects a provider in **Settings → Connected accounts** | -| `user.sso.profile_synced` | `{ userId, providerId, fields, trigger }` | Profile fields were copied from a provider - by **Sync now**, **Import** or an opted-in sign-in | -| `user.passkey.created` | `{ userId, passkeyId }` | A member adds a [passkey](/docs/dev/passkeys) in **Settings → Security** | -| `user.passkey.updated` | `{ userId, passkeyId, name }` | A member renames one of their passkeys | -| `user.passkey.deleted` | `{ userId, passkeyId }` | A member removes one of their passkeys | -| `user.avatar.updated` | `{ userId, fileId }` | An avatar is uploaded or removed - by the user on their profile, or by staff in the AdminCP | -| `user.cover.updated` | `{ userId, fileId }` | A profile cover is uploaded or removed - by the user on their profile, or by staff in the AdminCP | -| `role.created` | `{ roleId }` | A role is created in the AdminCP | -| `role.updated` | `{ roleId }` | A role is edited in the AdminCP | -| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP | +| Event | Payload | Fires when | +| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `user.created` | `{ userId, email, name, emailVerified }` | A user row is inserted - public sign-up, AdminCP creation, or SSO sign-up | +| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP, or a member saves their own profile or time zone in **Settings → Overview** | +| `user.deleted` | `{ userId, email }` | Never - the name is declared for plugins, core has no deletion flow | +| `user.password.updated` | `{ userId }` | A password is set - by a member completing a reset, or by staff on the AdminCP user page | +| `user.sessions.revoked` | `{ userId, deviceId, sessions }` | A user is signed out remotely - one device or all of them, by the member or by staff, or after a password change | +| `user.sso.linked` | `{ userId, email, providerId }` | An SSO identity is linked to an existing account - at login with its password, or from **Settings → Connected accounts** | +| `user.sso.unlinked` | `{ userId, providerId }` | A provider is disconnected - by the member in **Settings → Connected accounts**, or by staff in the AdminCP | +| `user.sso.preferences_updated` | `{ userId, sources, sync }` | Profile-field sources or sync-on-sign-in settings are saved - by the member or by staff | +| `user.sso.profile_synced` | `{ userId, providerId, fields, trigger }` | Profile fields were copied from a provider - by **Sync now**, **Import** or an opted-in sign-in | +| `user.passkey.created` | `{ userId, passkeyId }` | A member adds a [passkey](/docs/dev/passkeys) in **Settings → Security** | +| `user.passkey.updated` | `{ userId, passkeyId, name }` | A passkey is renamed - by its owner or by staff | +| `user.passkey.deleted` | `{ userId, passkeyId }` | A passkey is removed - by its owner or by staff | +| `user.avatar.updated` | `{ userId, fileId }` | An avatar is uploaded or removed - by the user on their profile, or by staff in the AdminCP | +| `user.cover.updated` | `{ userId, fileId }` | A profile cover is uploaded or removed - by the user on their profile, or by staff in the AdminCP | +| `role.created` | `{ roleId }` | A role is created in the AdminCP | +| `role.updated` | `{ roleId }` | A role is edited in the AdminCP | +| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP | @@ -267,6 +270,87 @@ avatar that failed to download never counts. A name change also emits **A listener would** refresh a search index or a cached member card. + + + +Emitted after a new password hash is stored - when a member completes a +password reset, or when staff set one from the AdminCP user page. The staff +route answers `403` while password sign-in is turned off, so this never fires +for a password nobody could use. Both paths then sign the account out +everywhere, so `user.sessions.revoked` follows right after. + + + +**A listener would** email the account owner that their password changed, and +use the envelope's `actor` to say whether they or an administrator did it. + + + + +Emitted by `revokeSessions` once a user's sessions are deleted and their cached +copies dropped. It covers both session kinds - the site session and the AdminCP +session - and fires from every place that signs somebody out remotely: a member +ending one of their own devices, staff ending one device or all of them, and a +password change. + + + +**A listener would** write a security audit entry, or warn the member when +staff signed them out. + + + + +Emitted by `SsoConnectionModel.savePreferences` after the profile-field sources +and the per-provider sync switches are saved - from **Settings → Connected +accounts** or from the AdminCP user page. It fires on every successful save, +even when nothing changed. + +>", + }, + sync: { + description: 'The submitted sync-on-sign-in switch per provider id.', + type: 'Record', + }, + }} +/> + +**A listener would** audit-log who changed where an account's profile comes +from. + @@ -404,23 +488,27 @@ or the bottom bar, never both. ## Notification events -Seven names, declared in `VitNodeEvents` in -`packages/vitnode/src/api/models/events.ts`. Each fires **after** an +Eight names, declared in `VitNodeEvents` in +`packages/vitnode/src/api/models/events.ts`. Seven of them fire **after** an administrator's change in [AdminCP → Notifications](/docs/dev/notifications/admincp) -has been saved. The envelope's `actor` is that administrator. - -| Event | Payload | Fires when | -| --------------------------------- | -------------------- | -------------------------------------------------------------------------- | -| `notifications.type.updated` | `{ typeId }` | What a type does by default (list, push, email, member can edit) changes | -| `notifications.preferences_reset` | `{ members }` | **Reset all members to defaults** ran - `members` is how many were reset | -| `notifications.paused` | `{}` | Notifications were paused from the danger zone | -| `notifications.resumed` | `{ requeuedEvents }` | Notifications were resumed - `requeuedEvents` waited while paused | -| `notifications.emails_cancelled` | `{ count }` | Queued emails were cancelled - `count` is how many deliveries were skipped | -| `notifications.read_all` | `{ items, members }` | Everything was marked as read for everyone | -| `notifications.deleted_all` | `{ items }` | Every member's notifications were deleted | +has been saved, and the envelope's `actor` is that administrator. +`notifications.preferences_updated` is about one member: it fires when their +own preferences are saved, by them or by staff on their AdminCP user page. + +| Event | Payload | Fires when | +| ----------------------------------- | --------------------- | -------------------------------------------------------------------------- | +| `notifications.type.updated` | `{ typeId }` | What a type does by default (list, push, email, member can edit) changes | +| `notifications.preferences_updated` | `{ userId, typeIds }` | One member's preferences are saved - `typeIds` are the types in the save | +| `notifications.preferences_reset` | `{ members }` | **Reset all members to defaults** ran - `members` is how many were reset | +| `notifications.paused` | `{}` | Notifications were paused from the danger zone | +| `notifications.resumed` | `{ requeuedEvents }` | Notifications were resumed - `requeuedEvents` waited while paused | +| `notifications.emails_cancelled` | `{ count }` | Queued emails were cancelled - `count` is how many deliveries were skipped | +| `notifications.read_all` | `{ items, members }` | Everything was marked as read for everyone | +| `notifications.deleted_all` | `{ items }` | Every member's notifications were deleted | **A listener would** post an audit entry to your staff channel when someone -pauses, deletes or resets everything. +pauses, deletes or resets everything, or note when staff changed how a single +member is notified. ## Page layout events diff --git a/apps/web/content/docs/dev/working-with-users/users.mdx b/apps/web/content/docs/dev/working-with-users/users.mdx index 446ccf82c..d95e820c3 100644 --- a/apps/web/content/docs/dev/working-with-users/users.mdx +++ b/apps/web/content/docs/dev/working-with-users/users.mdx @@ -242,6 +242,53 @@ Administrators can view, edit, ban, and assign roles to users at **Core → User --- +### Edit a user + +{/* Image prompt: VitNode AdminCP user page at /admin/core/users/1. Left column: profile card with cover, round avatar, name, @handle, Staff and Verified badges, then Personal information, Preferences, Roles, Connected accounts and Devices cards. Right column: segmented tabs Activity, Notifications, Password & passkeys above a vertical activity timeline. Dark theme, 1440x1000. */} + +Open a user at `/admin/core/users/[id]`. Everything the member can change in their own `/settings` is editable here too. + +| Area | What staff can change | +| :----------------------- | :------------------------------------------------------------------------------------------------------ | +| Profile card | Display name, name code, email, avatar, cover, and **Mark email as verified** | +| Personal information | First name, last name, headline, phone, birthday, **Show real name** | +| Preferences | Language, time zone (or automatic), newsletter | +| Roles | Primary role and secondary roles | +| Connected accounts | Disconnect an SSO account, choose where the avatar and name come from, turn **Sync on sign-in** on/off | +| Devices | Sign out one device, or every device at once | +| **Notifications** tab | In-app, push and email (with frequency) per notification type | +| **Password & passkeys** tab | Set a new password, rename or delete passkeys | + +A few rules differ from the member's own settings: + +- **Staff can edit locked fields.** The role's **Allow editing personal information** and the install's `users.personalInformation` switches limit members, not staff. +- **Notification locks still apply.** Mandatory types, and types the notification settings stop members from changing, are read-only here too, because delivery ignores stored choices for them. +- **The last sign-in method can't be removed.** Disconnecting the only SSO account or deleting the only passkey answers `409`. +- **Setting a password signs the member out everywhere**, AdminCP sessions included. +- **Sync now isn't offered.** It needs the member to sign in with the provider themselves. + +Every write needs `users:can_edit`, plus `users:can_edit_admin` when the target is staff. Reads need `users:can_view`. + +| Method | Path | Does | +| :------- | :------------------------------------------------------------------ | :--------------------------------------------------- | +| `PATCH` | `/api/@vitnode/core/admin/users/{id}` | Account, personal information and preferences | +| `PUT` | `/api/@vitnode/core/admin/users/{id}/password` | Sets a new password and revokes every session | +| `GET` | `/api/@vitnode/core/admin/users/{id}/devices` | Devices with an active session | +| `DELETE` | `/api/@vitnode/core/admin/users/{id}/devices/{publicId}` | Signs one device out | +| `DELETE` | `/api/@vitnode/core/admin/users/{id}/devices` | Signs every device out | +| `GET` | `/api/@vitnode/core/admin/users/{id}/passkeys` | Passkeys | +| `PATCH` | `/api/@vitnode/core/admin/users/{id}/passkeys/{passkeyId}` | Renames a passkey | +| `DELETE` | `/api/@vitnode/core/admin/users/{id}/passkeys/{passkeyId}` | Deletes a passkey | +| `GET` | `/api/@vitnode/core/admin/users/{id}/sso` | Connected accounts, sign-in summary, profile sources | +| `DELETE` | `/api/@vitnode/core/admin/users/{id}/sso/{providerId}` | Disconnects an account | +| `PUT` | `/api/@vitnode/core/admin/users/{id}/sso/preferences` | Profile sources and sync on sign-in | +| `GET` | `/api/@vitnode/core/admin/users/{id}/notification-preferences` | Notification preferences per type | +| `PUT` | `/api/@vitnode/core/admin/users/{id}/notification-preferences` | Changes notification preferences | + +The `PATCH` body takes any of `email`, `name`, `nameCode`, `roleId`, `secondaryRoleIds`, `firstName`, `lastName`, `headline`, `phone`, `showRealName`, `birthday` (`YYYY-MM-DD` or `null`), `language`, `timeZone` (`null` for automatic) and `newsletter`. At least one field is required. + +--- + ### Where it happens - **Profile page** (`/users/[nameCode]`): the owner sees a camera button on the diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 7cfc683cb..de84fa86f 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -877,7 +877,157 @@ "secondaryRoles": "Role dodatkowe", "selectRole": "Wybierz rolę", "title": "Profil użytkownika", - "updateSuccess": "Profil został zaktualizowany." + "updateSuccess": "Profil został zaktualizowany.", + "verify": { + "action": "Oznacz e-mail jako zweryfikowany", + "success": "E-mail oznaczony jako zweryfikowany", + "successDesc": "{name} może teraz otrzymywać e-maile z witryny." + }, + "badges": { + "staff": "Zespół", + "verified": "Zweryfikowany", + "unverified": "Niezweryfikowany" + }, + "tabs": { + "profileLabel": "Profil członka", + "detailsLabel": "Szczegóły członka", + "activity": "Aktywność", + "notifications": "Powiadomienia", + "security": "Hasło i klucze dostępu", + "securityShort": "Bezpieczeństwo" + }, + "personal": { + "title": "Dane osobowe", + "edit": "Edytuj dane osobowe", + "editDesc": "Opcjonalne dane z profilu członka. Puste pola zostaną wyczyszczone.", + "save": "Zapisz zmiany", + "saved": "Dane osobowe zapisane", + "savedDesc": "Profil użytkownika {name} pokazuje nowe dane.", + "firstName": "Imię", + "lastName": "Nazwisko", + "headline": "Nagłówek", + "headlineDesc": "Wyświetlany pod nazwą w profilu. Do {max} znaków.", + "phone": "Telefon", + "birthday": "Data urodzenia", + "showRealName": "Pokazuj prawdziwe imię", + "showRealNameDesc": "Pokazuje imię i nazwisko obok nazwy wyświetlanej.", + "memberId": "ID członka", + "realName": "Imię i nazwisko", + "hidden": "ukryte", + "notSet": "Nie ustawiono" + }, + "preferences": { + "title": "Preferencje", + "edit": "Edytuj preferencje", + "editDesc": "Język, strefa czasowa i newsletter tego członka.", + "save": "Zapisz zmiany", + "saved": "Preferencje zapisane", + "savedDesc": "{name} zobaczy zmianę po odświeżeniu strony.", + "language": "Język", + "timeZone": "Strefa czasowa", + "timeZoneDesc": "Używana w e-mailach z podsumowaniem i datach. Automatyczna wynika z języka.", + "timeZoneAuto": "Automatyczna", + "newsletter": "Newsletter", + "newsletterDesc": "Otrzymuje newslettery wysyłane z AdminCP.", + "subscribed": "Zapisany", + "notSubscribed": "Niezapisany" + }, + "sso": { + "title": "Połączone konta", + "edit": "Edytuj źródło profilu i synchronizację", + "editTitle": "Źródło profilu i synchronizacja", + "editDesc": "Wybierz, skąd pochodzą awatar i imię użytkownika {name} oraz czy odświeżają się przy każdym logowaniu.", + "syncTitle": "Synchronizacja przy logowaniu", + "syncFooter": "Synchronizacja odświeża powyższe pola z tego konta przy każdym logowaniu przez nie.", + "syncNoFields": "Nie jest jeszcze źródłem żadnego pola", + "syncOn": "{provider} synchronizuje przy logowaniu", + "syncOff": "Synchronizacja {provider} wyłączona", + "disconnect": "Odłącz {provider}", + "disconnectTitle": "Odłączyć {provider}?", + "disconnectDesc": "{name} nie będzie mógł logować się przez {provider}. Hasło i inne połączenia pozostaną bez zmian.", + "disconnectSubmit": "Odłącz", + "disconnected": "Odłączono {provider}", + "disconnectedDesc": "{name} nie może już logować się przez to konto.", + "connected": "Połączono", + "sourceBadge": "{fields} stąd", + "syncsBadge": "Synchronizuje przy logowaniu", + "lastMethod": "Nie można usunąć ostatniego sposobu logowania", + "lastMethodDesc": "Najpierw ustaw hasło lub dodaj inny sposób logowania.", + "loadError": "Nie udało się wczytać połączonych kont. Odśwież, aby spróbować ponownie.", + "empty": "Brak połączonych kont. Ten członek loguje się hasłem lub kluczem dostępu." + }, + "devices": { + "title": "Urządzenia", + "deviceName": "{browser} na {os}", + "unknownDevice": "Nieznane urządzenie", + "signOut": "Wyloguj to urządzenie", + "signOutLabel": "Wyloguj {device}", + "signOutTitle": "Wylogować to urządzenie?", + "signOutDesc": "{name} zostanie wylogowany na {device}, łącznie z sesją AdminCP.", + "signOutSubmit": "Wyloguj", + "signedOut": "Urządzenie wylogowane", + "signedOutDesc": "{device} musi zalogować się ponownie.", + "signOutAll": "Wyloguj wszędzie", + "signOutAllTitle": "Wylogować {name} wszędzie?", + "signOutAllDesc": "{count, plural, one {Sesja na # urządzeniu zostanie zakończona} few {Wszystkie sesje na # urządzeniach zostaną zakończone} many {Wszystkie sesje na # urządzeniach zostaną zakończone} other {Wszystkie sesje na # urządzeniach zostaną zakończone}}, łącznie z sesjami AdminCP. Można od razu zalogować się ponownie.", + "signedOutAll": "Wylogowano wszędzie", + "signedOutAllDesc": "{name} musi zalogować się ponownie na każdym urządzeniu.", + "admincp": "AdminCP", + "showAll": "Pokaż wszystkie ({count})", + "showFewer": "Pokaż mniej", + "loadError": "Nie udało się wczytać urządzeń. Odśwież, aby spróbować ponownie.", + "empty": "Nie jest zalogowany na żadnym urządzeniu." + }, + "security": { + "title": "Hasło i klucze dostępu", + "password": "Hasło", + "passwordSet": "Hasło ustawione", + "passwordNotSet": "Brak hasła", + "passwordDisabled": "Logowanie hasłem jest wyłączone w tej witrynie.", + "passwordLoadError": "Nie udało się wczytać stanu hasła. Odśwież, aby spróbować ponownie.", + "passwordChange": "Zmień", + "passwordAdd": "Ustaw hasło", + "passwordTitle": "Ustaw nowe hasło", + "passwordDesc": "{name} zostanie wylogowany ze wszystkich urządzeń i przy następnym logowaniu musi użyć nowego hasła.", + "newPassword": "Nowe hasło", + "confirmPassword": "Potwierdź hasło", + "passwordMin": "Użyj co najmniej {min} znaków.", + "passwordMismatch": "Hasła nie są takie same.", + "passwordSubmit": "Ustaw hasło", + "passwordSaved": "Hasło zmienione", + "passwordSavedDesc": "{name} został wylogowany wszędzie.", + "passkeys": "Klucze dostępu", + "passkeysEmpty": "Brak kluczy dostępu.", + "passkeysDisabled": "Klucze dostępu są wyłączone w tej witrynie.", + "passkeysLoadError": "Nie udało się wczytać kluczy dostępu. Odśwież, aby spróbować ponownie.", + "passkeySynced": "Synchronizowany", + "passkeyDeviceOnly": "Tylko to urządzenie", + "passkeyUsed": "Użyty", + "passkeyNeverUsed": "Nigdy nieużyty", + "passkeyRename": "Zmień nazwę klucza", + "passkeyRenameLabel": "Zmień nazwę {name}", + "passkeyRenameTitle": "Zmień nazwę klucza dostępu", + "passkeyRenameDesc": "Zmienia się tylko etykieta, klucz działa dalej.", + "passkeyName": "Nazwa", + "passkeyRenameSubmit": "Zapisz nazwę", + "passkeyRenamed": "Zmieniono nazwę klucza", + "passkeyDelete": "Usuń klucz dostępu", + "passkeyDeleteLabel": "Usuń {name}", + "passkeyDeleteTitle": "Usunąć ten klucz dostępu?", + "passkeyDeleteDesc": "{name} nie będzie mógł logować się kluczem „{passkey}”.", + "passkeyDeleteSubmit": "Usuń klucz", + "passkeyDeleted": "Klucz dostępu usunięty", + "passkeyDeletedDesc": "„{passkey}” nie loguje już do tego konta.", + "lastMethod": "Nie można usunąć ostatniego sposobu logowania", + "lastMethodDesc": "Najpierw ustaw hasło lub połącz inny sposób logowania." + }, + "notifications": { + "desc": "Każdy typ korzysta z ustawień powiadomień witryny, dopóki go tu nie zmienisz. Zmiany dotyczą tylko użytkownika {name}.", + "empty": "W tej witrynie nie włączono żadnych typów powiadomień.", + "saved": "Preferencja powiadomień zapisana", + "savedDesc": "Dotyczy tylko użytkownika {name}.", + "loadError": "Nie udało się wczytać preferencji powiadomień. Odśwież, aby spróbować ponownie." + } }, "verify_email": { "label": "Zweryfikuj adres e-mail", diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index a25b70867..15e3cce47 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -35,6 +35,10 @@ export interface VitNodeEvents { "notifications.preferences_reset": { members: number; }; + "notifications.preferences_updated": { + typeIds: string[]; + userId: number; + }; "notifications.read_all": { items: number; members: number; @@ -89,11 +93,24 @@ export interface VitNodeEvents { passkeyId: number; userId: number; }; + "user.password.updated": { + userId: number; + }; + "user.sessions.revoked": { + deviceId: null | number; + sessions: number; + userId: number; + }; "user.sso.linked": { email: string; providerId: string; userId: number; }; + "user.sso.preferences_updated": { + sources: Partial>; + sync: Record; + userId: number; + }; "user.sso.profile_synced": { fields: SsoProfileField[]; providerId: string; diff --git a/packages/vitnode/src/api/models/notifications/preferences.ts b/packages/vitnode/src/api/models/notifications/preferences.ts index e3c00270a..559b0ebff 100644 --- a/packages/vitnode/src/api/models/notifications/preferences.ts +++ b/packages/vitnode/src/api/models/notifications/preferences.ts @@ -205,4 +205,9 @@ export const updateNotificationPreferences = async ( }) .where(eq(core_notification_user_state.userId, userId)); }); + + await c.get("events").emit("notifications.preferences_updated", { + typeIds: Object.keys(patch), + userId, + }); }; diff --git a/packages/vitnode/src/api/models/session-revoke.ts b/packages/vitnode/src/api/models/session-revoke.ts index 0055a877f..9756df0c8 100644 --- a/packages/vitnode/src/api/models/session-revoke.ts +++ b/packages/vitnode/src/api/models/session-revoke.ts @@ -116,4 +116,9 @@ export const revokeSessions = async ( ]); await deleteSessionCacheKeys(c, { adminSessions, sessions }); + await c.get("events").emit("user.sessions.revoked", { + deviceId: scope.deviceId ?? null, + sessions: sessions.length + adminSessions.length, + userId: scope.userId, + }); }; diff --git a/packages/vitnode/src/api/models/sso-connection.ts b/packages/vitnode/src/api/models/sso-connection.ts index 484edfb28..969d2e3cb 100644 --- a/packages/vitnode/src/api/models/sso-connection.ts +++ b/packages/vitnode/src/api/models/sso-connection.ts @@ -786,6 +786,10 @@ export class SsoConnectionModel { if (outcome === "not_connected") { throw new SsoConnectionError("not_connected", 409); } + + await this.c + .get("events") + .emit("user.sso.preferences_updated", { sources, sync, userId }); } private get adapters(): SSOApiPlugin[] { diff --git a/packages/vitnode/src/api/models/user-devices.ts b/packages/vitnode/src/api/models/user-devices.ts new file mode 100644 index 000000000..1a024c581 --- /dev/null +++ b/packages/vitnode/src/api/models/user-devices.ts @@ -0,0 +1,179 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { and, eq, gt, inArray } from "drizzle-orm"; + +import { core_admin_sessions } from "@/database/admins"; +import { + core_sessions, + core_sessions_known_devices, +} from "@/database/sessions"; +import { parseUserAgent } from "@/lib/api/parse-user-agent"; + +export const SESSION_KINDS = ["user", "admin"] as const; +type SessionKind = (typeof SESSION_KINDS)[number]; + +export const zodUserDeviceSchema = z.object({ + publicId: z.string(), + ipAddress: z.string(), + os: z.string(), + browser: z.string(), + deviceType: z.enum(["desktop", "tablet", "mobile"]), + lastSeen: z.date(), + expiresAt: z.date(), + sessionKinds: z.array(z.enum(SESSION_KINDS)), +}); + +export type UserDevice = z.infer; + +interface ActiveSession { + deviceId: number; + expiresAt: Date; + kind: SessionKind; +} + +interface DeviceSessions { + expiresAt: Date; + kinds: Set; +} + +const activeSessionsOf = async ( + c: Context, + userId: number, +): Promise => { + const db = c.get("db"); + const now = new Date(); + + const [userSessions, adminSessions] = await Promise.all([ + db + .select({ + deviceId: core_sessions.deviceId, + expiresAt: core_sessions.expiresAt, + }) + .from(core_sessions) + .where( + and(eq(core_sessions.userId, userId), gt(core_sessions.expiresAt, now)), + ), + db + .select({ + deviceId: core_admin_sessions.deviceId, + expiresAt: core_admin_sessions.expiresAt, + }) + .from(core_admin_sessions) + .where( + and( + eq(core_admin_sessions.userId, userId), + gt(core_admin_sessions.expiresAt, now), + ), + ), + ]); + + return [ + ...userSessions.map(session => ({ ...session, kind: "user" as const })), + ...adminSessions.map(session => ({ ...session, kind: "admin" as const })), + ]; +}; + +const groupByDevice = ( + sessions: ActiveSession[], +): Map => { + const byDevice = new Map(); + for (const { deviceId, expiresAt, kind } of sessions) { + const existing = byDevice.get(deviceId); + if (!existing) { + byDevice.set(deviceId, { expiresAt, kinds: new Set([kind]) }); + continue; + } + existing.kinds.add(kind); + if (expiresAt > existing.expiresAt) existing.expiresAt = expiresAt; + } + + return byDevice; +}; + +export const listUserDevices = async ( + c: Context, + userId: number, +): Promise => { + const sessionsByDevice = groupByDevice(await activeSessionsOf(c, userId)); + if (sessionsByDevice.size === 0) return []; + + const rows = await c + .get("db") + .select({ + id: core_sessions_known_devices.id, + publicId: core_sessions_known_devices.publicId, + ipAddress: core_sessions_known_devices.ipAddress, + userAgent: core_sessions_known_devices.userAgent, + lastSeen: core_sessions_known_devices.lastSeen, + }) + .from(core_sessions_known_devices) + .where( + inArray(core_sessions_known_devices.id, [...sessionsByDevice.keys()]), + ); + + return rows + .flatMap(({ id, userAgent, ...device }) => { + const sessions = sessionsByDevice.get(id); + if (!sessions) return []; + + return [ + { + ...device, + ...parseUserAgent(userAgent), + expiresAt: sessions.expiresAt, + sessionKinds: SESSION_KINDS.filter(kind => sessions.kinds.has(kind)), + }, + ]; + }) + .sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()); +}; + +const hasSessionOn = async ( + c: Context, + { deviceId, userId }: { deviceId: number; userId: number }, +): Promise => { + const db = c.get("db"); + + const [userSessions, adminSessions] = await Promise.all([ + db + .select({ deviceId: core_sessions.deviceId }) + .from(core_sessions) + .where( + and( + eq(core_sessions.userId, userId), + eq(core_sessions.deviceId, deviceId), + ), + ) + .limit(1), + db + .select({ deviceId: core_admin_sessions.deviceId }) + .from(core_admin_sessions) + .where( + and( + eq(core_admin_sessions.userId, userId), + eq(core_admin_sessions.deviceId, deviceId), + ), + ) + .limit(1), + ]); + + return userSessions.length > 0 || adminSessions.length > 0; +}; + +export const findUserDeviceId = async ( + c: Context, + { publicId, userId }: { publicId: string; userId: number }, +): Promise => { + const [device] = await c + .get("db") + .select({ id: core_sessions_known_devices.id }) + .from(core_sessions_known_devices) + .where(eq(core_sessions_known_devices.publicId, publicId)); + + if (!device) return null; + + return (await hasSessionOn(c, { deviceId: device.id, userId })) + ? device.id + : null; +}; diff --git a/packages/vitnode/src/api/modules/admin/users/lib/target-user.ts b/packages/vitnode/src/api/modules/admin/users/lib/target-user.ts new file mode 100644 index 000000000..37b77268a --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/lib/target-user.ts @@ -0,0 +1,47 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import { core_users } from "@/database/users"; + +const MAX_USER_ID = 2_147_483_647; + +export const zodTargetUserIdParam = z.string().openapi({ example: "1" }); + +export const zodTargetUserParams = z.object({ id: zodTargetUserIdParam }); + +export const userNotFoundResponse = { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "User not found", +}; + +export const USER_NOT_FOUND = { error: "User not found" }; + +const parseUserId = (id: string): null | number => { + if (!/^[1-9]\d{0,9}$/.test(id)) return null; + const userId = Number(id); + + return userId <= MAX_USER_ID ? userId : null; +}; + +export const findTargetUserId = async ( + c: Context, + id: string, +): Promise => { + const userId = parseUserId(id); + if (userId === null) return null; + + const [user] = await c + .get("db") + .select({ id: core_users.id }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + + return user?.id ?? null; +}; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts new file mode 100644 index 000000000..cd86e606c --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.test.ts @@ -0,0 +1,344 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { buildModule } from "@/api/lib/module"; +import { + adminSessionCacheKey, + sessionCacheKey, +} from "@/api/models/session-cache"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_admin_permissions, core_admin_sessions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { core_roles } from "@/database/roles"; +import { + core_sessions, + core_sessions_known_devices, +} from "@/database/sessions"; +import { core_users, core_users_secondary_roles } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { listUserDevicesAdminRoute } from "./devices.route"; +import { revokeUserDeviceAdminRoute } from "./revoke-device.route"; +import { revokeUserDevicesAdminRoute } from "./revoke-devices.route"; + +const MEMBER_ROLE = 3; +const EDITOR = { email: "editor@example.com", id: 1, roleId: MEMBER_ROLE }; +const TARGET_ID = 7; +const OTHER_ID = 8; +const MODERATOR_ID = 9; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_VIEW = permission("can_view"); +const CAN_EDIT = permission("can_edit"); +const CAN_EDIT_ADMIN = permission("can_edit_admin"); + +const PHONE = { id: 4, lastSeen: new Date("2026-09-30"), publicId: "phone" }; +const OFFICE = { id: 5, lastSeen: new Date("2026-09-20"), publicId: "office" }; +const LAPTOP = { id: 3, lastSeen: new Date("2026-09-10"), publicId: "laptop" }; +const TABLET = { id: 6, lastSeen: new Date("2026-09-25"), publicId: "tablet" }; + +const userRow = (id: number) => ({ + email: `${id}@example.com`, + id, + name: `user-${id}`, + nameCode: `user-${id}`, + roleId: MEMBER_ROLE, +}); + +const harness = async (editor: PermissionsStaffArgs[]) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + + const expiresAt = new Date(Date.now() + 1000 * 60 * 30); + const expired = new Date(Date.now() - 1000); + const session = (userId: number, deviceId: number, at = expiresAt) => ({ + deviceId, + expiresAt: at, + token: `token-${userId}-${deviceId}`, + userId, + }); + + const memory = createMemoryDb([ + [ + core_users, + [EDITOR, userRow(TARGET_ID), userRow(OTHER_ID), userRow(MODERATOR_ID)], + ], + [core_users_secondary_roles, []], + [core_roles, [{ guest: false, id: MEMBER_ROLE, root: false }]], + [core_admin_permissions, []], + [ + core_moderators_permissions, + [ + { + permissions: [], + roleId: null, + unrestricted: false, + userId: MODERATOR_ID, + }, + ], + ], + [ + core_sessions_known_devices, + [PHONE, OFFICE, LAPTOP, TABLET].map(device => ({ + ipAddress: "203.0.113.7", + userAgent: "node", + ...device, + })), + ], + [ + core_sessions, + [ + session(TARGET_ID, PHONE.id), + session(TARGET_ID, LAPTOP.id), + session(TARGET_ID, TABLET.id, expired), + session(OTHER_ID, TABLET.id), + session(MODERATOR_ID, PHONE.id), + ], + ], + [ + core_admin_sessions, + [session(TARGET_ID, PHONE.id), session(TARGET_ID, OFFICE.id)], + ], + ]); + + await cache.setSystem( + sessionCacheKey(`token-${TARGET_ID}-${PHONE.id}`, PHONE.id), + { + cached: true, + }, + ); + await cache.setSystem( + adminSessionCacheKey(`token-${TARGET_ID}-${OFFICE.id}`, OFFICE.id), + { cached: true }, + ); + + const emit = vi.fn(async () => Promise.resolve(undefined)); + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + app.route( + "/", + buildModule({ + name: "users", + pluginId: CONFIG_PLUGIN.pluginId, + routes: [ + listUserDevicesAdminRoute, + revokeUserDevicesAdminRoute, + revokeUserDeviceAdminRoute, + ], + }).hono, + ); + + const sessionsOf = (userId: number) => ({ + admin: memory + .rows(core_admin_sessions) + .filter(row => row.userId === userId) + .map(row => row.deviceId), + user: memory + .rows(core_sessions) + .filter(row => row.userId === userId) + .map(row => row.deviceId), + }); + + const isCached = async () => ({ + admin: + (await cache.getSystem( + adminSessionCacheKey(`token-${TARGET_ID}-${OFFICE.id}`, OFFICE.id), + )) !== null, + user: + (await cache.getSystem( + sessionCacheKey(`token-${TARGET_ID}-${PHONE.id}`, PHONE.id), + )) !== null, + }); + + const request = async (path: string, method = "GET") => + await app.request(path, { method }); + + return { emit, isCached, request, sessionsOf }; +}; + +describe("GET /admin/users/{id}/devices", () => { + it("lists only the user's active devices, newest first, with session kinds", async () => { + const h = await harness([CAN_VIEW]); + + const response = await h.request(`/${TARGET_ID}/devices`); + + expect(response.status).toBe(200); + const { devices } = (await response.json()) as { + devices: Record[]; + }; + expect( + devices.map(({ publicId, sessionKinds }) => ({ publicId, sessionKinds })), + ).toEqual([ + { publicId: PHONE.publicId, sessionKinds: ["user", "admin"] }, + { publicId: OFFICE.publicId, sessionKinds: ["admin"] }, + { publicId: LAPTOP.publicId, sessionKinds: ["user"] }, + ]); + expect(devices[0]).not.toHaveProperty("isCurrent"); + }); + + it("returns 404 for an unknown user", async () => { + const h = await harness([CAN_VIEW]); + + const response = await h.request("/404/devices"); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }); + + it("refuses a caller without users:can_view", async () => { + const h = await harness([CAN_EDIT]); + + expect((await h.request(`/${TARGET_ID}/devices`)).status).toBe(403); + }); +}); + +describe("DELETE /admin/users/{id}/devices/{publicId}", () => { + it("ends both session kinds on that device and drops their cache", async () => { + const h = await harness([CAN_EDIT]); + + const response = await h.request( + `/${TARGET_ID}/devices/${PHONE.publicId}`, + "DELETE", + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.sessionsOf(TARGET_ID)).toEqual({ + admin: [OFFICE.id], + user: [LAPTOP.id, TABLET.id], + }); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([PHONE.id]); + expect((await h.isCached()).user).toBe(false); + expect(h.emit).toHaveBeenCalledWith("user.sessions.revoked", { + deviceId: PHONE.id, + sessions: 2, + userId: TARGET_ID, + }); + }); + + it("returns 404 for a device the user has no session on", async () => { + const h = await harness([CAN_EDIT]); + + const response = await h.request(`/${OTHER_ID}/devices/phone`, "DELETE"); + + expect(response.status).toBe(404); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([PHONE.id]); + }); + + it("returns 404 for an unknown device", async () => { + const h = await harness([CAN_EDIT]); + + expect( + (await h.request(`/${TARGET_ID}/devices/missing`, "DELETE")).status, + ).toBe(404); + }); + + it("rejects a malformed device id", async () => { + const h = await harness([CAN_EDIT]); + + expect( + (await h.request(`/${TARGET_ID}/devices/bad%20id`, "DELETE")).status, + ).toBe(400); + }); + + it("refuses a caller without users:can_edit", async () => { + const h = await harness([CAN_VIEW]); + + const response = await h.request( + `/${TARGET_ID}/devices/${PHONE.publicId}`, + "DELETE", + ); + + expect(response.status).toBe(403); + expect(h.sessionsOf(TARGET_ID).user).toContain(PHONE.id); + expect(h.emit).not.toHaveBeenCalled(); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness([CAN_EDIT]); + + const response = await h.request( + `/${MODERATOR_ID}/devices/${PHONE.publicId}`, + "DELETE", + ); + + expect(response.status).toBe(403); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([PHONE.id]); + }); + + it("lets users:can_edit_admin revoke a staff target's device", async () => { + const h = await harness([CAN_EDIT, CAN_EDIT_ADMIN]); + + const response = await h.request( + `/${MODERATOR_ID}/devices/${PHONE.publicId}`, + "DELETE", + ); + + expect(response.status).toBe(200); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([]); + }); +}); + +describe("DELETE /admin/users/{id}/devices", () => { + it("ends every session of the user and only that user", async () => { + const h = await harness([CAN_EDIT]); + + const response = await h.request(`/${TARGET_ID}/devices`, "DELETE"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.sessionsOf(TARGET_ID)).toEqual({ admin: [], user: [] }); + expect(h.sessionsOf(OTHER_ID).user).toEqual([TABLET.id]); + expect(await h.isCached()).toEqual({ admin: false, user: false }); + expect(h.emit).toHaveBeenCalledWith("user.sessions.revoked", { + deviceId: null, + sessions: 5, + userId: TARGET_ID, + }); + }); + + it("returns 404 for an unknown user", async () => { + const h = await harness([CAN_EDIT]); + + expect((await h.request("/404/devices", "DELETE")).status).toBe(404); + }); + + it("refuses a caller without users:can_edit", async () => { + const h = await harness([CAN_VIEW]); + + expect((await h.request(`/${TARGET_ID}/devices`, "DELETE")).status).toBe( + 403, + ); + expect(h.sessionsOf(TARGET_ID).admin).toHaveLength(2); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness([CAN_EDIT]); + + expect((await h.request(`/${MODERATOR_ID}/devices`, "DELETE")).status).toBe( + 403, + ); + expect(h.sessionsOf(MODERATOR_ID).user).toEqual([PHONE.id]); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/devices.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.ts new file mode 100644 index 000000000..78084dd8c --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/devices.route.ts @@ -0,0 +1,50 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { + listUserDevices, + zodUserDeviceSchema, +} from "@/api/models/user-devices"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const listUserDevicesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_view" }, + route: { + method: "get", + description: + "List the devices a user is signed in on, with a session of either kind (Admin only)", + path: "/{id}/devices", + request: { + params: z.object({ id: zodTargetUserIdParam }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ devices: z.array(zodUserDeviceSchema) }), + }, + }, + description: "The user's devices, most recently seen first", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + return c.json({ devices: await listUserDevices(c, userId) }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences-update.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences-update.route.ts new file mode 100644 index 000000000..74ad19240 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences-update.route.ts @@ -0,0 +1,70 @@ +import { buildRoute } from "@/api/lib/route"; +import { + getNotificationPreferences, + updateNotificationPreferences, +} from "@/api/models/notifications/preferences"; +import { + zodNotificationPreferences, + zodUpdateNotificationPreferences, +} from "@/api/modules/notifications/routes/preferences.route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +export const updateUserNotificationPreferencesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "put", + description: + "Save a user's notification preferences. Staff may change types the installation locks for members, but never mandatory ones (Admin only)", + path: "/{id}/notification-preferences", + request: { + params: zodTargetUserParams, + body: { + required: true, + content: { + "application/json": { schema: zodUpdateNotificationPreferences }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodNotificationPreferences }, + }, + description: "Saved, with the preferences as they are now", + }, + 400: { + description: + "An unknown or mandatory type, or a channel the type does not offer", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const userId = await findTargetUserId(c, c.req.valid("param").id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + await assertCanEditAdminTarget(c, userId); + await updateNotificationPreferences(c, userId, c.req.valid("json")); + + return c.json( + await getNotificationPreferences(c, { + language: c.get("admin")?.user.language, + userId, + }), + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts new file mode 100644 index 000000000..ce8c0fde4 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.test.ts @@ -0,0 +1,345 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import type { NotificationTypePolicy } from "@/api/lib/notifications/preferences"; +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; +import type { NotificationTypePreference } from "@/database/notifications"; + +import { + buildNotificationType, + createNotificationRegistry, +} from "@/api/lib/notifications/registry"; +import { core_admin_permissions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { + core_notification_settings, + core_notification_user_state, +} from "@/database/notifications"; +import { core_roles } from "@/database/roles"; +import { core_users, core_users_secondary_roles } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { updateUserNotificationPreferencesAdminRoute } from "./notification-preferences-update.route"; +import { listUserNotificationPreferencesAdminRoute } from "./notification-preferences.route"; + +const STAFF_ROLE = 2; +const MEMBER_ROLE = 3; +const EDITOR = { + email: "editor@example.com", + id: 1, + language: "en", + roleId: STAFF_ROLE, +}; +const TARGET_ID = 7; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_VIEW = permission("can_view"); +const CAN_EDIT = permission("can_edit"); + +const notificationType = ( + id: string, + overrides: { email?: boolean; mandatory?: boolean } = {}, +) => ({ + ...buildNotificationType({ + category: "social", + defaults: { + email: overrides.email === false ? "none" : "daily", + inApp: true, + }, + email: overrides.email ?? true, + id, + label: id, + present: () => ({ title: "t" }), + schema: z.object({}), + version: 1, + }), + mandatory: overrides.mandatory, +}); + +const COMMENT = "blog.comment"; +const LOCKED = "blog.digest"; +const SECURITY = "security.alert"; +const NO_EMAIL = "blog.like"; + +const translator = Object.assign((key: string) => key, { + has: () => false, +}); + +const harness = async ({ + editor = [CAN_VIEW, CAN_EDIT], + policies = { [LOCKED]: { memberCanEdit: false } }, + states = [], + targetIsStaff = false, +}: { + editor?: PermissionsStaffArgs[]; + policies?: Record; + states?: { + preferences: Record; + userId: number; + }[]; + targetIsStaff?: boolean; +} = {}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + if (targetIsStaff) { + await grantStaffPermissions(cache, { + permissions: [], + type: "moderator", + userId: TARGET_ID, + }); + } + + const memory = createMemoryDb([ + [ + core_users, + [ + EDITOR, + { + email: "target@example.com", + id: TARGET_ID, + name: "Target", + nameCode: "target", + roleId: MEMBER_ROLE, + }, + ], + ], + [core_users_secondary_roles, []], + [ + core_roles, + [ + { guest: false, id: STAFF_ROLE, root: false }, + { guest: false, id: MEMBER_ROLE, root: false }, + ], + ], + [core_admin_permissions, []], + [core_moderators_permissions, []], + [ + core_notification_settings, + Object.entries(policies).map(([typeId, value]) => ({ + key: `type:${typeId}`, + value, + })), + ], + [core_notification_user_state, states], + ]); + + const registry = createNotificationRegistry( + [ + notificationType(COMMENT), + notificationType(LOCKED), + notificationType(SECURITY, { mandatory: true }), + notificationType(NO_EMAIL, { email: false }), + ].map(definition => ({ definition, pluginId: "@acme/blog" })), + ); + + const emit = vi.fn(async () => Promise.resolve(undefined)); + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("core", { + email: { adapter: {} }, + notifications: registry, + } as unknown as EnvVariablesVitNode["core"]); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + c.set("i18n", { + getTranslator: async () => await Promise.resolve(translator), + resolveSupportedLocale: () => "en", + } as unknown as Context["var"]["i18n"]); + await next(); + }); + for (const { handler, route } of [ + listUserNotificationPreferencesAdminRoute, + updateUserNotificationPreferencesAdminRoute, + ]) { + app.openapi(route, handler); + } + + const put = async (types: Record) => + await app.request(`/${TARGET_ID}/notification-preferences`, { + body: JSON.stringify({ types }), + headers: { "content-type": "application/json" }, + method: "PUT", + }); + + const preferencesOf = (userId: number) => + memory.rows(core_notification_user_state).find(row => row.userId === userId) + ?.preferences; + + return { emit, preferencesOf, put, request: app.request.bind(app) }; +}; + +interface PreferencesBody { + types: { + id: string; + locked: boolean; + mandatory: boolean; + value: { email: string; inApp: boolean; push: boolean }; + }[]; +} + +const typeOf = (body: PreferencesBody, id: string) => + body.types.find(type => type.id === id); + +describe("GET /admin/users/{id}/notification-preferences", () => { + it("returns the target user's choices, not the admin's", async () => { + const h = await harness({ + states: [ + { preferences: { [COMMENT]: { email: "none" } }, userId: EDITOR.id }, + { + preferences: { [COMMENT]: { email: "weekly", inApp: false } }, + userId: TARGET_ID, + }, + ], + }); + + const response = await h.request(`/${TARGET_ID}/notification-preferences`); + + expect(response.status).toBe(200); + const body = (await response.json()) as PreferencesBody; + expect(typeOf(body, COMMENT)?.value).toEqual({ + email: "weekly", + inApp: false, + push: true, + }); + }); + + it("locks mandatory types and types the installation locks for members", async () => { + const h = await harness(); + + const body = (await ( + await h.request(`/${TARGET_ID}/notification-preferences`) + ).json()) as PreferencesBody; + + expect( + body.types.map(({ id, locked, mandatory }) => [id, locked, mandatory]), + ).toEqual([ + [COMMENT, false, false], + [LOCKED, true, false], + [SECURITY, true, true], + [NO_EMAIL, false, false], + ]); + }); + + it("answers 404 for an unknown user", async () => { + const h = await harness(); + + const response = await h.request("/999/notification-preferences"); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }); + + it("refuses staff without users:can_view", async () => { + const h = await harness({ editor: [] }); + + const response = await h.request(`/${TARGET_ID}/notification-preferences`); + + expect(response.status).toBe(403); + }); +}); + +describe("PUT /admin/users/{id}/notification-preferences", () => { + it("persists to the target user's state and answers with the new preferences", async () => { + const h = await harness({ + states: [ + { preferences: { [COMMENT]: { email: "none" } }, userId: EDITOR.id }, + { preferences: { [NO_EMAIL]: { inApp: false } }, userId: TARGET_ID }, + ], + }); + + const response = await h.put({ [COMMENT]: { email: "immediate" } }); + + expect(response.status).toBe(200); + const body = (await response.json()) as PreferencesBody; + expect(typeOf(body, COMMENT)?.value.email).toBe("immediate"); + expect(h.preferencesOf(TARGET_ID)).toEqual({ + [COMMENT]: { email: "immediate" }, + [NO_EMAIL]: { inApp: false }, + }); + expect(h.preferencesOf(EDITOR.id)).toEqual({ + [COMMENT]: { email: "none" }, + }); + expect(h.emit).toHaveBeenCalledWith("notifications.preferences_updated", { + typeIds: [COMMENT], + userId: TARGET_ID, + }); + }); + + it("creates the target user's state when they never saved preferences", async () => { + const h = await harness(); + + const response = await h.put({ [COMMENT]: { push: false } }); + + expect(response.status).toBe(200); + expect(h.preferencesOf(TARGET_ID)).toEqual({ [COMMENT]: { push: false } }); + }); + + it("refuses a type the installation locks for members", async () => { + const h = await harness(); + + const response = await h.put({ [LOCKED]: { email: "weekly" } }); + + expect(response.status).toBe(400); + expect(h.preferencesOf(TARGET_ID)).toBeUndefined(); + }); + + it.each([ + ["a mandatory type", { [SECURITY]: { email: "none" as const } }], + ["an unknown type", { "blog.unknown": { inApp: true } }], + [ + "an email channel the type lacks", + { [NO_EMAIL]: { email: "daily" as const } }, + ], + ])("rejects %s", async (_, types) => { + const h = await harness(); + + const response = await h.put(types); + + expect(response.status).toBe(400); + expect(h.preferencesOf(TARGET_ID)).toBeUndefined(); + }); + + it("rejects channels the installation switched off", async () => { + const h = await harness({ + policies: { [COMMENT]: { allowInApp: false, allowPush: false } }, + }); + + expect((await h.put({ [COMMENT]: { inApp: true } })).status).toBe(400); + expect((await h.put({ [COMMENT]: { push: true } })).status).toBe(400); + }); + + it("refuses staff without users:can_edit", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + const response = await h.put({ [COMMENT]: { email: "weekly" } }); + + expect(response.status).toBe(403); + expect(h.preferencesOf(TARGET_ID)).toBeUndefined(); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ targetIsStaff: true }); + + const response = await h.put({ [COMMENT]: { email: "weekly" } }); + + expect(response.status).toBe(403); + expect(h.preferencesOf(TARGET_ID)).toBeUndefined(); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.ts new file mode 100644 index 000000000..f76466e00 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/notification-preferences.route.ts @@ -0,0 +1,50 @@ +import { buildRoute } from "@/api/lib/route"; +import { getNotificationPreferences } from "@/api/models/notifications/preferences"; +import { zodNotificationPreferences } from "@/api/modules/notifications/routes/preferences.route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +export const listUserNotificationPreferencesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_view" }, + route: { + method: "get", + description: + "Every notification type the installation offers, with the channels it may use and what a user currently receives. Only mandatory types are locked for staff (Admin only)", + path: "/{id}/notification-preferences", + request: { + params: zodTargetUserParams, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodNotificationPreferences }, + }, + description: "Notification preferences", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const userId = await findTargetUserId(c, c.req.valid("param").id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + return c.json( + await getNotificationPreferences(c, { + language: c.get("admin")?.user.language, + userId, + }), + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/passkey-delete.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/passkey-delete.route.ts new file mode 100644 index 000000000..d8e2327a0 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/passkey-delete.route.ts @@ -0,0 +1,68 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { passkeyFailure } from "@/api/modules/users/passkeys/failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyIdParam, +} from "@/api/modules/users/passkeys/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const deleteUserPasskeyAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "delete", + description: + "Remove one of a user's passkeys. Refused when it is the account's last way to sign in (Admin only)", + path: "/{id}/passkeys/{passkeyId}", + request: { + params: z.object({ + id: zodTargetUserIdParam, + passkeyId: zodPasskeyIdParam, + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ ok: z.literal(true) }), + }, + }, + description: "Passkey removed", + }, + ...PASSKEY_ERROR_RESPONSES, + 404: { + ...userNotFoundResponse, + description: "Unknown user, unknown passkey, or passkeys are disabled", + }, + }, + }, + handler: async c => { + const { id, passkeyId } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + await assertCanEditAdminTarget(c, userId); + + try { + await new PasskeyModel(c).deletePasskey({ + id: Number(passkeyId), + userId, + }); + + return c.json({ ok: true as const }, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/passkey-rename.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/passkey-rename.route.ts new file mode 100644 index 000000000..6810fca74 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/passkey-rename.route.ts @@ -0,0 +1,79 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { passkeyFailure } from "@/api/modules/users/passkeys/failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeyIdParam, + zodPasskeyNameSchema, + zodPasskeySchema, +} from "@/api/modules/users/passkeys/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const renameUserPasskeyAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "patch", + description: "Rename one of a user's passkeys (Admin only)", + path: "/{id}/passkeys/{passkeyId}", + request: { + params: z.object({ + id: zodTargetUserIdParam, + passkeyId: zodPasskeyIdParam, + }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ name: zodPasskeyNameSchema }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: zodPasskeySchema, + }, + }, + description: "Passkey renamed", + }, + ...PASSKEY_ERROR_RESPONSES, + 404: { + ...userNotFoundResponse, + description: "Unknown user, unknown passkey, or passkeys are disabled", + }, + }, + }, + handler: async c => { + const { id, passkeyId } = c.req.valid("param"); + const { name } = c.req.valid("json"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + await assertCanEditAdminTarget(c, userId); + + try { + const passkey = await new PasskeyModel(c).renamePasskey({ + id: Number(passkeyId), + name, + userId, + }); + + return c.json(passkey, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts new file mode 100644 index 000000000..97c7ba0ca --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.test.ts @@ -0,0 +1,350 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { buildModule } from "@/api/lib/module"; +import { PasskeyModel } from "@/api/models/passkey"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_admin_permissions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { core_roles } from "@/database/roles"; +import { core_users, core_users_secondary_roles } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + createMemoryPasskeyStore, + type MemoryPasskeyAccount, +} from "@/tests/passkey-store"; +import { SESSION_AUTHORIZATION } from "@/tests/sessions"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { deleteUserPasskeyAdminRoute } from "./passkey-delete.route"; +import { renameUserPasskeyAdminRoute } from "./passkey-rename.route"; +import { listUserPasskeysAdminRoute } from "./passkeys.route"; + +const MEMBER_ROLE = 3; +const EDITOR = { email: "editor@example.com", id: 1, roleId: MEMBER_ROLE }; +const TARGET_ID = 7; +const OTHER_ID = 8; +const MODERATOR_ID = 9; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_VIEW = permission("can_view"); +const CAN_EDIT = permission("can_edit"); +const CAN_EDIT_ADMIN = permission("can_edit_admin"); + +const userRow = (id: number) => ({ + email: `${id}@example.com`, + id, + name: `user-${id}`, + nameCode: `user-${id}`, + roleId: MEMBER_ROLE, +}); + +const newPasskey = (userId: number, name: string) => ({ + aaguid: null, + backedUp: true, + counter: 0, + credentialId: `credential-${userId}-${name}`, + deviceType: "multiDevice", + name, + publicKey: "public-key", + transports: ["internal"], + userId, + webauthnUserId: `webauthn-${userId}`, +}); + +const harness = async ({ + accounts = { + [MODERATOR_ID]: { hasPassword: true, ssoProviders: [] }, + [OTHER_ID]: { hasPassword: true, ssoProviders: [] }, + [TARGET_ID]: { hasPassword: true, ssoProviders: [] }, + }, + editor, +}: { + accounts?: Record; + editor: PermissionsStaffArgs[]; +}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + + const memory = createMemoryPasskeyStore(accounts); + vi.spyOn(PasskeyModel.prototype, "store", "get").mockReturnValue( + memory.store, + ); + const targetKey = await memory.store.createPasskey( + newPasskey(TARGET_ID, "Laptop"), + ); + const otherKey = await memory.store.createPasskey( + newPasskey(OTHER_ID, "Phone"), + ); + const moderatorKey = await memory.store.createPasskey( + newPasskey(MODERATOR_ID, "Key"), + ); + if (!(targetKey && otherKey && moderatorKey)) { + throw new Error("seed failed"); + } + + const memoryDb = createMemoryDb([ + [ + core_users, + [EDITOR, userRow(TARGET_ID), userRow(OTHER_ID), userRow(MODERATOR_ID)], + ], + [core_users_secondary_roles, []], + [core_roles, [{ guest: false, id: MEMBER_ROLE, root: false }]], + [core_admin_permissions, []], + [ + core_moderators_permissions, + [ + { + permissions: [], + roleId: null, + unrestricted: false, + userId: MODERATOR_ID, + }, + ], + ], + ]); + const emit = vi.fn(async () => Promise.resolve(undefined)); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("core", { + authorization: { + ...SESSION_AUTHORIZATION, + passkeys: { + enabled: true, + origins: ["https://example.com"], + rpId: "example.com", + rpName: "VitNode", + }, + }, + } as unknown as EnvVariablesVitNode["core"]); + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memoryDb.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + app.route( + "/", + buildModule({ + name: "users", + pluginId: CONFIG_PLUGIN.pluginId, + routes: [ + listUserPasskeysAdminRoute, + renameUserPasskeyAdminRoute, + deleteUserPasskeyAdminRoute, + ], + }).hono, + ); + + const list = async (userId: number) => + await app.request(`/${userId}/passkeys`); + + const rename = async (userId: number, passkeyId: number, name: string) => + await app.request(`/${userId}/passkeys/${passkeyId}`, { + body: JSON.stringify({ name }), + headers: { "content-type": "application/json" }, + method: "PATCH", + }); + + const remove = async (userId: number, passkeyId: number) => + await app.request(`/${userId}/passkeys/${passkeyId}`, { + method: "DELETE", + }); + + return { + emit, + keys: { moderator: moderatorKey, other: otherKey, target: targetKey }, + list, + passkeys: memory.passkeys, + remove, + rename, + }; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("GET /admin/users/{id}/passkeys", () => { + it("lists only that user's passkeys, without secrets", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + const response = await h.list(TARGET_ID); + + expect(response.status).toBe(200); + const { items } = (await response.json()) as { + items: Record[]; + }; + expect(items).toEqual([ + expect.objectContaining({ + backedUp: true, + deviceType: "multiDevice", + id: h.keys.target.id, + lastUsedAt: null, + name: "Laptop", + transports: ["internal"], + }), + ]); + expect(items[0]).not.toHaveProperty("publicKey"); + expect(items[0]).not.toHaveProperty("credentialId"); + }); + + it("returns 404 for an unknown user", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + const response = await h.list(404); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }); + + it("refuses a caller without users:can_view", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + expect((await h.list(TARGET_ID)).status).toBe(403); + }); +}); + +describe("PATCH /admin/users/{id}/passkeys/{passkeyId}", () => { + it("renames the user's passkey and emits the update", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.rename(TARGET_ID, h.keys.target.id, " Desk "); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + id: h.keys.target.id, + name: "Desk", + }); + expect(h.passkeys.get(h.keys.target.id)?.name).toBe("Desk"); + expect(h.emit).toHaveBeenCalledWith("user.passkey.updated", { + name: "Desk", + passkeyId: h.keys.target.id, + userId: TARGET_ID, + }); + }); + + it("returns not_found for another user's passkey", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.rename(TARGET_ID, h.keys.other.id, "Stolen"); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "not_found" }); + expect(h.passkeys.get(h.keys.other.id)?.name).toBe("Phone"); + }); + + it("rejects a blank name", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + expect((await h.rename(TARGET_ID, h.keys.target.id, " ")).status).toBe( + 400, + ); + }); + + it("refuses a caller without users:can_edit", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + expect((await h.rename(TARGET_ID, h.keys.target.id, "Desk")).status).toBe( + 403, + ); + expect(h.passkeys.get(h.keys.target.id)?.name).toBe("Laptop"); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.rename(MODERATOR_ID, h.keys.moderator.id, "Desk"); + + expect(response.status).toBe(403); + expect(h.passkeys.get(h.keys.moderator.id)?.name).toBe("Key"); + }); + + it("lets users:can_edit_admin rename a staff target's passkey", async () => { + const h = await harness({ editor: [CAN_EDIT, CAN_EDIT_ADMIN] }); + + const response = await h.rename(MODERATOR_ID, h.keys.moderator.id, "Desk"); + + expect(response.status).toBe(200); + }); +}); + +describe("DELETE /admin/users/{id}/passkeys/{passkeyId}", () => { + it("removes the user's passkey and emits the deletion", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.remove(TARGET_ID, h.keys.target.id); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.passkeys.has(h.keys.target.id)).toBe(false); + expect(h.emit).toHaveBeenCalledWith("user.passkey.deleted", { + passkeyId: h.keys.target.id, + userId: TARGET_ID, + }); + }); + + it("refuses removing the account's last way to sign in", async () => { + const h = await harness({ + accounts: { [TARGET_ID]: { hasPassword: false, ssoProviders: [] } }, + editor: [CAN_EDIT], + }); + + const response = await h.remove(TARGET_ID, h.keys.target.id); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "last_recovery_method" }); + expect(h.passkeys.has(h.keys.target.id)).toBe(true); + }); + + it("returns not_found for another user's passkey", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.remove(TARGET_ID, h.keys.other.id); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "not_found" }); + expect(h.passkeys.has(h.keys.other.id)).toBe(true); + }); + + it("returns 404 for an unknown user", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.remove(404, h.keys.target.id); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }); + + it("refuses a caller without users:can_edit", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + + expect((await h.remove(TARGET_ID, h.keys.target.id)).status).toBe(403); + expect(h.passkeys.has(h.keys.target.id)).toBe(true); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + expect((await h.remove(MODERATOR_ID, h.keys.moderator.id)).status).toBe( + 403, + ); + expect(h.passkeys.has(h.keys.moderator.id)).toBe(true); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.ts new file mode 100644 index 000000000..8feae4748 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/passkeys.route.ts @@ -0,0 +1,58 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasskeyModel } from "@/api/models/passkey"; +import { passkeyFailure } from "@/api/modules/users/passkeys/failure"; +import { + PASSKEY_ERROR_RESPONSES, + zodPasskeySchema, +} from "@/api/modules/users/passkeys/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const listUserPasskeysAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_view" }, + route: { + method: "get", + description: "List a user's passkeys (Admin only)", + path: "/{id}/passkeys", + request: { + params: z.object({ id: zodTargetUserIdParam }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ items: z.array(zodPasskeySchema) }), + }, + }, + description: "The user's passkeys", + }, + ...PASSKEY_ERROR_RESPONSES, + 404: { + ...userNotFoundResponse, + description: "Unknown user, or passkeys are disabled", + }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + try { + const items = await new PasskeyModel(c).listPasskeys(userId); + + return c.json({ items }, 200); + } catch (error) { + return passkeyFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts new file mode 100644 index 000000000..124211a1d --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; + +import { PasswordModel } from "@/api/models/password"; +import { + adminSessionCacheKey, + sessionCacheKey, +} from "@/api/models/session-cache"; +import { core_admin_sessions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { core_sessions } from "@/database/sessions"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { setPasswordUserAdminRoute } from "./password.route"; + +const EDITOR = { email: "editor@example.com", id: 1, roleId: 2 }; +const TARGET_ID = 7; +const OTHER_ID = 8; +const OLD_HASH = "old-salt:old-key"; +const NEW_PASSWORD = "Sup3rSecret!"; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_EDIT = permission("can_edit"); + +const session = (id: number, userId: number, deviceId: number) => ({ + deviceId, + expiresAt: new Date(), + id, + token: `token-${id}`, + userId, +}); + +const harness = async ({ + editor, + passwordEnabled = true, + targetIsModerator = false, +}: { + editor: PermissionsStaffArgs[]; + passwordEnabled?: boolean; + targetIsModerator?: boolean; +}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + + const memory = createMemoryDb([ + [ + core_users, + [ + EDITOR, + { id: TARGET_ID, password: OLD_HASH, roleId: 3 }, + { id: OTHER_ID, password: OLD_HASH, roleId: 3 }, + ], + ], + [ + core_sessions, + [ + session(1, TARGET_ID, 10), + session(2, TARGET_ID, 11), + session(3, OTHER_ID, 12), + ], + ], + [core_admin_sessions, [session(4, TARGET_ID, 10)]], + [ + core_moderators_permissions, + targetIsModerator + ? [ + { + permissions: [], + roleId: null, + unrestricted: false, + userId: TARGET_ID, + }, + ] + : [], + ], + ]); + + await Promise.all([ + cache.setSystem(sessionCacheKey("token-1", 10), { id: TARGET_ID }), + cache.setSystem(adminSessionCacheKey("token-4", 10), { id: TARGET_ID }), + ]); + + const emit = vi.fn(async () => Promise.resolve(undefined)); + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("core", { + authorization: { password: { enabled: passwordEnabled } }, + } as unknown as EnvVariablesVitNode["core"]); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + app.openapi( + setPasswordUserAdminRoute.route, + setPasswordUserAdminRoute.handler, + ); + + const put = async (userId: number | string, password: string) => + await app.request(`/${userId}/password`, { + body: JSON.stringify({ password }), + headers: { "content-type": "application/json" }, + method: "PUT", + }); + + const storedHash = (userId: number) => + memory.rows(core_users).find(row => row.id === userId)?.password; + + const sessionUserIds = () => + memory.rows(core_sessions).map(row => row.userId); + + const adminSessionUserIds = () => + memory.rows(core_admin_sessions).map(row => row.userId); + + return { + adminSessionUserIds, + cache, + emit, + put, + sessionUserIds, + storedHash, + }; +}; + +describe("PUT /admin/users/{id}/password", () => { + it("stores a hash that verifies against the new password", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.put(TARGET_ID, NEW_PASSWORD); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + const stored = h.storedHash(TARGET_ID); + expect(typeof stored).toBe("string"); + expect(stored).not.toBe(NEW_PASSWORD); + expect( + await new PasswordModel().verifyPassword(NEW_PASSWORD, String(stored)), + ).toBe(true); + expect(h.storedHash(OTHER_ID)).toBe(OLD_HASH); + }); + + it("signs the member out of every device and drops their cached sessions", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + await h.put(TARGET_ID, NEW_PASSWORD); + + expect(h.sessionUserIds()).toEqual([OTHER_ID]); + expect(h.adminSessionUserIds()).toEqual([]); + expect(await h.cache.getSystem(sessionCacheKey("token-1", 10))).toBeNull(); + expect( + await h.cache.getSystem(adminSessionCacheKey("token-4", 10)), + ).toBeNull(); + }); + + it("emits the password change and the sign-out of every device", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + await h.put(TARGET_ID, NEW_PASSWORD); + + expect(h.emit).toHaveBeenCalledWith("user.password.updated", { + userId: TARGET_ID, + }); + expect(h.emit).toHaveBeenCalledWith("user.sessions.revoked", { + deviceId: null, + sessions: 3, + userId: TARGET_ID, + }); + }); + + it("refuses while password sign-in is disabled and changes nothing", async () => { + const h = await harness({ editor: [CAN_EDIT], passwordEnabled: false }); + + const response = await h.put(TARGET_ID, NEW_PASSWORD); + + expect(response.status).toBe(403); + expect(h.storedHash(TARGET_ID)).toBe(OLD_HASH); + expect(h.sessionUserIds()).toHaveLength(3); + expect(h.adminSessionUserIds()).toEqual([TARGET_ID]); + expect(h.emit).not.toHaveBeenCalled(); + }); + + it("refuses an editor without users:can_edit", async () => { + const h = await harness({ editor: [permission("can_view")] }); + + const response = await h.put(TARGET_ID, NEW_PASSWORD); + + expect(response.status).toBe(403); + expect(h.storedHash(TARGET_ID)).toBe(OLD_HASH); + expect(h.sessionUserIds()).toHaveLength(3); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ editor: [CAN_EDIT], targetIsModerator: true }); + + const response = await h.put(TARGET_ID, NEW_PASSWORD); + + expect(response.status).toBe(403); + expect(h.storedHash(TARGET_ID)).toBe(OLD_HASH); + }); + + it.each([404, "not-a-number"])( + "answers 404 for the unknown user %s", + async userId => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.put(userId, NEW_PASSWORD); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + }, + ); + + it("refuses a password shorter than eight characters", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.put(TARGET_ID, "short"); + + expect(response.status).toBe(400); + expect(h.storedHash(TARGET_ID)).toBe(OLD_HASH); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts new file mode 100644 index 000000000..54558d73b --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts @@ -0,0 +1,97 @@ +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; +import { buildRoute } from "@/api/lib/route"; +import { PasswordModel } from "@/api/models/password"; +import { revokeSessions } from "@/api/models/session-revoke"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_users } from "@/database/users"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; + +export const zodSetPasswordUserAdminSchema = z.object({ + password: z.string().min(8).openapi({ example: "Test123!" }), +}); + +export const setPasswordUserAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "put", + description: + "Set a user's password by id and sign them out of every device (Admin only)", + path: "/{id}/password", + request: { + params: z.object({ + id: z.string().openapi({ example: "1" }), + }), + body: { + required: true, + content: { + "application/json": { + schema: zodSetPasswordUserAdminSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ ok: z.literal(true) }), + }, + }, + description: "Password set and every session revoked", + }, + 400: { + description: "Invalid password", + }, + 403: { + description: "Access Denied, or password sign-in is disabled", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "User not found", + }, + }, + }, + handler: async c => { + assertPasswordSignInEnabled(c); + const { id } = c.req.valid("param"); + const { password } = c.req.valid("json"); + const userId = Number(id); + if (!Number.isInteger(userId)) { + return c.json({ error: "User not found" }, 404); + } + + const db = c.get("db"); + + const [user] = await db + .select({ id: core_users.id }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + + if (!user) { + return c.json({ error: "User not found" }, 404); + } + + await assertCanEditAdminTarget(c, user.id); + + const hashedPassword = await new PasswordModel().encryptPassword(password); + await db + .update(core_users) + .set({ password: hashedPassword }) + .where(eq(core_users.id, user.id)); + await c.get("events").emit("user.password.updated", { userId: user.id }); + + await revokeSessions(c, { userId: user.id }); + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts new file mode 100644 index 000000000..e95bcb470 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts @@ -0,0 +1,70 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { revokeSessions } from "@/api/models/session-revoke"; +import { findUserDeviceId } from "@/api/models/user-devices"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + USER_NOT_FOUND, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const revokeUserDeviceAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "delete", + description: + "Sign a user out of one device, ending both its user and AdminCP sessions (Admin only)", + path: "/{id}/devices/{publicId}", + request: { + params: z.object({ + id: zodTargetUserIdParam, + publicId: z + .string() + .regex(/^[A-Za-z0-9_-]{1,128}$/) + .openapi({ example: "a1b2c3" }), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ ok: z.literal(true) }), + }, + }, + description: "Device signed out", + }, + 403: { + description: "Access Denied", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "Unknown user, or the user has no session on that device", + }, + }, + }, + handler: async c => { + const { id, publicId } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + await assertCanEditAdminTarget(c, userId); + + const deviceId = await findUserDeviceId(c, { publicId, userId }); + if (deviceId === null) { + return c.json({ error: "Device not found" }, 404); + } + + await revokeSessions(c, { deviceId, userId }); + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts new file mode 100644 index 000000000..c88e1e256 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts @@ -0,0 +1,52 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { revokeSessions } from "@/api/models/session-revoke"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + USER_NOT_FOUND, + userNotFoundResponse, + zodTargetUserIdParam, +} from "../lib/target-user"; + +export const revokeUserDevicesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "delete", + description: + "Sign a user out of every device, ending all user and AdminCP sessions (Admin only)", + path: "/{id}/devices", + request: { + params: z.object({ id: zodTargetUserIdParam }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ ok: z.literal(true) }), + }, + }, + description: "Every session of the user ended", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) return c.json(USER_NOT_FOUND, 404); + + await assertCanEditAdminTarget(c, userId); + + await revokeSessions(c, { userId }); + + return c.json({ ok: true as const }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts index 2023c2965..fcb2d0601 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts @@ -31,6 +31,11 @@ export const showUserAdminRoute = buildRoute({ name: z.string(), email: z.string(), nameCode: z.string(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + phone: z.string().nullable(), + headline: z.string().nullable(), + showRealName: z.boolean(), createdAt: z.date(), newsletter: z.boolean(), avatarColor: z.string(), @@ -42,6 +47,7 @@ export const showUserAdminRoute = buildRoute({ secondaryRoles: z.array(userRoleSchema), birthday: z.date().nullable(), language: z.string(), + timeZone: z.string().nullable(), isStaff: z.boolean(), imagePolicy: zodUserImagePolicy, }), diff --git a/packages/vitnode/src/api/modules/admin/users/routes/sso-disconnect.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/sso-disconnect.route.ts new file mode 100644 index 000000000..d202886d3 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/sso-disconnect.route.ts @@ -0,0 +1,71 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { SsoConnectionModel } from "@/api/models/sso-connection"; +import { ssoConnectionFailure } from "@/api/modules/users/sso/connections/failure"; +import { + zodSsoConnectionErrorSchema, + zodSsoProviderIdParam, +} from "@/api/modules/users/sso/connections/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +export const disconnectUserSsoAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "delete", + description: + "Remove a user's connection to a provider and its profile sync settings. Refused when it is the account's last way to sign in. Does not revoke anything at the provider (Admin only)", + path: "/{id}/sso/{providerId}", + request: { + params: zodTargetUserParams.extend({ + providerId: zodSsoProviderIdParam, + }), + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Connection removed", + }, + 403: { + description: "Access Denied", + }, + 404: { + ...userNotFoundResponse, + description: "User not found, or not connected to the provider", + }, + 409: { + content: { + "application/json": { schema: zodSsoConnectionErrorSchema }, + }, + description: "The connection is the account's last way to sign in", + }, + }, + }, + handler: async c => { + const { id, providerId } = c.req.valid("param"); + const userId = await findTargetUserId(c, id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + await assertCanEditAdminTarget(c, userId); + + try { + await new SsoConnectionModel(c).disconnect({ providerId, userId }); + + return c.json({ ok: true as const }, 200); + } catch (error) { + return ssoConnectionFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/sso-preferences.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/sso-preferences.route.ts new file mode 100644 index 000000000..802dddc78 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/sso-preferences.route.ts @@ -0,0 +1,78 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { SsoConnectionModel } from "@/api/models/sso-connection"; +import { ssoConnectionFailure } from "@/api/modules/users/sso/connections/failure"; +import { zodSsoPreferencesSchema } from "@/api/modules/users/sso/connections/routes/preferences.route"; +import { zodSsoConnectionErrorSchema } from "@/api/modules/users/sso/connections/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +const ssoErrorResponse = (description: string) => ({ + content: { + "application/json": { schema: zodSsoConnectionErrorSchema }, + }, + description, +}); + +export const updateUserSsoPreferencesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_edit" }, + route: { + method: "put", + description: + "Choose which of a user's connected providers each profile field comes from (null keeps it managed manually), and whether signing in through a provider updates those fields (Admin only)", + path: "/{id}/sso/preferences", + request: { + params: zodTargetUserParams, + body: { + required: true, + content: { "application/json": { schema: zodSsoPreferencesSchema } }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ ok: z.literal(true) }) }, + }, + description: "Preferences saved", + }, + 400: ssoErrorResponse( + "A source or sync provider the user is not connected to", + ), + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + 409: ssoErrorResponse("A connection was removed while saving"), + }, + }, + handler: async c => { + const userId = await findTargetUserId(c, c.req.valid("param").id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + await assertCanEditAdminTarget(c, userId); + + const { sources, sync } = c.req.valid("json"); + + try { + await new SsoConnectionModel(c).savePreferences({ + sources, + sync, + userId, + }); + + return c.json({ ok: true as const }, 200); + } catch (error) { + return ssoConnectionFailure(c, error); + } + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts new file mode 100644 index 000000000..184163c77 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.test.ts @@ -0,0 +1,365 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { SsoConnectionModel } from "@/api/models/sso-connection"; +import { core_admin_permissions } from "@/database/admins"; +import { core_moderators_permissions } from "@/database/moderators"; +import { core_roles } from "@/database/roles"; +import { core_users, core_users_secondary_roles } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + createMemorySsoConnectionStore, + type MemorySsoAccount, +} from "@/tests/sso-connection-store"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { disconnectUserSsoAdminRoute } from "./sso-disconnect.route"; +import { updateUserSsoPreferencesAdminRoute } from "./sso-preferences.route"; +import { listUserSsoAdminRoute } from "./sso.route"; + +const STAFF_ROLE = 2; +const MEMBER_ROLE = 3; +const EDITOR = { email: "editor@example.com", id: 1, roleId: STAFF_ROLE }; +const TARGET_ID = 7; + +const permission = (name: string): PermissionsStaffArgs => ({ + module: "users", + permission: name, + plugin: "@vitnode/core", +}); + +const CAN_VIEW = permission("can_view"); +const CAN_EDIT = permission("can_edit"); +const CAN_EDIT_ADMIN = permission("can_edit_admin"); + +const account = ( + id: number, + overrides: Partial = {}, +): MemorySsoAccount => ({ + avatarId: null, + email: `user${id}@vitnode.test`, + firstName: null, + hasPassword: true, + id, + lastName: null, + name: `User${id}`, + passkeys: 0, + roleId: MEMBER_ROLE, + showRealName: false, + ...overrides, +}); + +const adapter = ( + id: string, + profileFields: SSOApiPlugin["profileFields"], +): SSOApiPlugin => ({ + fetchToken: async () => + await Promise.resolve({ access_token: "token", token_type: "Bearer" }), + fetchUser: async () => + await Promise.resolve({ email: "x@example.com", id: "x", username: "x" }), + getUrl: () => `https://${id}.example/oauth`, + id, + name: id, + profileFields, +}); + +const json = (body: unknown, method: string): RequestInit => ({ + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method, +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const harness = async ({ + editor = [CAN_VIEW, CAN_EDIT], + target = account(TARGET_ID), + targetIsStaff = false, +}: { + editor?: PermissionsStaffArgs[]; + target?: MemorySsoAccount; + targetIsStaff?: boolean; +} = {}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: editor, + userId: EDITOR.id, + }); + if (targetIsStaff) { + await grantStaffPermissions(cache, { + permissions: [], + type: "moderator", + userId: TARGET_ID, + }); + } + + const memory = createMemoryDb([ + [ + core_users, + [ + EDITOR, + { + email: target.email, + id: TARGET_ID, + name: "Target", + nameCode: "target", + roleId: MEMBER_ROLE, + }, + ], + ], + [core_users_secondary_roles, []], + [ + core_roles, + [ + { guest: false, id: STAFF_ROLE, root: false }, + { guest: false, id: MEMBER_ROLE, root: false }, + ], + ], + [core_admin_permissions, []], + [core_moderators_permissions, []], + ]); + + const sso = createMemorySsoConnectionStore([account(EDITOR.id), target]); + vi.spyOn(SsoConnectionModel.prototype, "store", "get").mockReturnValue( + sso.store, + ); + const emit = vi.fn(async () => Promise.resolve(undefined)); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("core", { + authorization: { + passkeys: { enabled: false, problems: [] }, + password: { enabled: true }, + ssoAdapters: [ + adapter("google", ["avatar", "firstName", "lastName"]), + adapter("discord", ["avatar"]), + ], + }, + } as unknown as EnvVariablesVitNode["core"]); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + await next(); + }); + for (const { handler, route } of [ + listUserSsoAdminRoute, + disconnectUserSsoAdminRoute, + updateUserSsoPreferencesAdminRoute, + ]) { + app.openapi(route, handler); + } + + return { emit, request: app.request.bind(app), sso }; +}; + +describe("GET /admin/users/{id}/sso", () => { + it("returns the target user's connections, sign-in methods and sources", async () => { + const h = await harness(); + h.sso.connect(TARGET_ID, "google", "g-7", { + providerEmail: "target@gmail.com", + }); + h.sso.connect(EDITOR.id, "discord", "d-1"); + h.sso.sources.push({ + field: "firstName", + providerId: "google", + userId: TARGET_ID, + }); + + const response = await h.request(`/${TARGET_ID}/sso`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + providers: { connection: null | { email: null | string }; id: string }[]; + signIn: { hasPassword: boolean }; + sources: Record; + }; + expect( + body.providers.map(one => [one.id, one.connection?.email ?? null]), + ).toEqual([ + ["google", "target@gmail.com"], + ["discord", null], + ]); + expect(body.providers[1]?.connection).toBeNull(); + expect(body.signIn.hasPassword).toBe(true); + expect(body.sources).toEqual({ + avatar: null, + firstName: "google", + lastName: null, + }); + }); + + it("answers 404 for an unknown or malformed user id", async () => { + const h = await harness(); + + for (const id of ["999", "abc", "1.5"]) { + const response = await h.request(`/${id}/sso`); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "User not found" }); + } + }); + + it("refuses staff without users:can_view", async () => { + const h = await harness({ editor: [] }); + + expect((await h.request(`/${TARGET_ID}/sso`)).status).toBe(403); + }); +}); + +describe("DELETE /admin/users/{id}/sso/{providerId}", () => { + it("removes the target user's connection and announces it", async () => { + const h = await harness(); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.sso.connections).toEqual([]); + expect(h.emit).toHaveBeenCalledWith("user.sso.unlinked", { + providerId: "google", + userId: TARGET_ID, + }); + }); + + it("refuses removing the account's last way to sign in", async () => { + const h = await harness({ + target: account(TARGET_ID, { hasPassword: false }), + }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "last_sign_in_method" }); + expect(h.sso.connections).toHaveLength(1); + }); + + it("answers 404 when the user is not connected to the provider", async () => { + const h = await harness(); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "not_connected" }); + }); + + it("refuses staff without users:can_edit", async () => { + const h = await harness({ editor: [CAN_VIEW] }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(403); + expect(h.sso.connections).toHaveLength(1); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ targetIsStaff: true }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(403); + expect(h.sso.connections).toHaveLength(1); + }); + + it("lets users:can_edit_admin disconnect a staff target", async () => { + const h = await harness({ + editor: [CAN_EDIT, CAN_EDIT_ADMIN], + targetIsStaff: true, + }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request(`/${TARGET_ID}/sso/google`, { + method: "DELETE", + }); + + expect(response.status).toBe(200); + }); +}); + +describe("PUT /admin/users/{id}/sso/preferences", () => { + it("saves the target user's sources and sync settings", async () => { + const h = await harness(); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request( + `/${TARGET_ID}/sso/preferences`, + json({ sources: { firstName: "google" }, sync: { google: true } }, "PUT"), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(h.sso.sources).toEqual([ + { field: "firstName", providerId: "google", userId: TARGET_ID }, + ]); + expect(h.sso.connections[0]?.syncOnSignIn).toBe(true); + expect(h.emit).toHaveBeenCalledWith("user.sso.preferences_updated", { + sources: { firstName: "google" }, + sync: { google: true }, + userId: TARGET_ID, + }); + }); + + it("rejects a provider the target user is not connected to", async () => { + const h = await harness(); + h.sso.connect(EDITOR.id, "google", "g-1"); + + const response = await h.request( + `/${TARGET_ID}/sso/preferences`, + json({ sources: { firstName: "google" }, sync: {} }, "PUT"), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_source" }); + expect(h.emit).not.toHaveBeenCalled(); + }); + + it("rejects a field the provider does not supply", async () => { + const h = await harness(); + h.sso.connect(TARGET_ID, "discord", "d-7"); + + const response = await h.request( + `/${TARGET_ID}/sso/preferences`, + json({ sources: { lastName: "discord" }, sync: {} }, "PUT"), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_source" }); + }); + + it("refuses a staff target without users:can_edit_admin", async () => { + const h = await harness({ targetIsStaff: true }); + h.sso.connect(TARGET_ID, "google", "g-7"); + + const response = await h.request( + `/${TARGET_ID}/sso/preferences`, + json({ sources: {}, sync: { google: true } }, "PUT"), + ); + + expect(response.status).toBe(403); + expect(h.sso.connections[0]?.syncOnSignIn).toBe(false); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/sso.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.ts new file mode 100644 index 000000000..47b3f005a --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/sso.route.ts @@ -0,0 +1,44 @@ +import { buildRoute } from "@/api/lib/route"; +import { SsoConnectionModel } from "@/api/models/sso-connection"; +import { zodSsoConnectionsOverview } from "@/api/modules/users/sso/connections/schema"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + findTargetUserId, + userNotFoundResponse, + zodTargetUserParams, +} from "../lib/target-user"; + +export const listUserSsoAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "users", permission: "can_view" }, + route: { + method: "get", + description: + "List the configured SSO providers with a user's connection to each, their sign-in methods and where their profile fields come from (Admin only)", + path: "/{id}/sso", + request: { + params: zodTargetUserParams, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodSsoConnectionsOverview }, + }, + description: "Providers, connections and profile sources", + }, + 403: { + description: "Access Denied", + }, + 404: userNotFoundResponse, + }, + }, + handler: async c => { + const userId = await findTargetUserId(c, c.req.valid("param").id); + if (userId === null) { + return c.json({ error: "User not found" }, 404); + } + + return c.json(await new SsoConnectionModel(c).overview(userId), 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts index 26e4ca0b9..fc5cd7493 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.test.ts @@ -6,10 +6,17 @@ import { describe, expect, it, vi } from "vitest"; import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import { sessionCacheKey } from "@/api/models/session-cache"; import { core_admin_permissions } from "@/database/admins"; +import { core_languages } from "@/database/languages"; import { core_moderators_permissions } from "@/database/moderators"; import { core_roles } from "@/database/roles"; -import { core_users, core_users_secondary_roles } from "@/database/users"; +import { core_sessions } from "@/database/sessions"; +import { + core_users, + core_users_secondary_roles, + core_users_sso_profile_sources, +} from "@/database/users"; import { createTestCache } from "@/tests/cache"; import { createMemoryDb } from "@/tests/memory-db"; import { @@ -26,6 +33,7 @@ const PLUGINS_ROLE = 9; const EDITOR = { email: "editor@example.com", id: 1, roleId: EDITOR_ROLE }; const TARGET_ID = 7; +const TARGET_SESSION = { deviceId: 4, token: "target-session-token" }; const permission = (name: string, module = "users"): PermissionsStaffArgs => ({ module, @@ -105,16 +113,33 @@ const harness = async ({ core_moderators_permissions, target === "moderator" ? [entryRow({ userId: TARGET_ID })] : [], ], + [ + core_languages, + [ + { code: "en", id: 1, name: "English" }, + { code: "pl", id: 2, name: "Polski" }, + ], + ], + [ + core_sessions, + [{ ...TARGET_SESSION, expiresAt: new Date(), id: 1, userId: TARGET_ID }], + ], + [ + core_users_sso_profile_sources, + [ + { field: "firstName", providerId: "github", userId: TARGET_ID }, + { field: "lastName", providerId: "github", userId: TARGET_ID }, + ], + ], ]); + const emit = vi.fn(async () => Promise.resolve()); const app = new OpenAPIHono(); app.use("*", async (c, next) => { c.set("admin", { user: EDITOR } as unknown as Context["var"]["admin"]); c.set("cache", cache); c.set("db", memory.db as unknown as Context["var"]["db"]); - c.set("events", { - emit: vi.fn(async () => Promise.resolve()), - } as unknown as Context["var"]["events"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); await next(); }); app.openapi(updateUserAdminRoute.route, updateUserAdminRoute.handler); @@ -135,7 +160,13 @@ const harness = async ({ .filter(row => row.userId === userId) .map(row => row.roleId); - return { patch, secondaryRoleIdsOf, userRow }; + const ssoSourcesOf = (userId: number) => + memory + .rows(core_users_sso_profile_sources) + .filter(row => row.userId === userId) + .map(row => row.field); + + return { cache, emit, patch, secondaryRoleIdsOf, ssoSourcesOf, userRow }; }; const NEW_EMAIL = { email: "attacker@example.com" }; @@ -242,3 +273,142 @@ describe("PATCH /admin/users/{id} - privilege ceiling", () => { expect(h.userRow(TARGET_ID)?.roleId).toBe(EDITOR_ROLE); }); }); + +describe("PATCH /admin/users/{id} - personal information and preferences", () => { + it("saves personal fields trimmed and clears the SSO source of an edited name", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { + firstName: " Ada ", + headline: "Team Manager", + phone: "+48 600 700 800", + showRealName: true, + }); + + expect(response.status).toBe(200); + expect(h.userRow(TARGET_ID)).toMatchObject({ + firstName: "Ada", + headline: "Team Manager", + phone: "+48 600 700 800", + showRealName: true, + }); + expect(h.ssoSourcesOf(TARGET_ID)).toEqual(["lastName"]); + expect(h.emit).toHaveBeenCalledWith( + "user.updated", + expect.objectContaining({ userId: TARGET_ID }), + ); + }); + + it("clears personal fields with null or an empty string", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + await h.patch(TARGET_ID, { headline: "Lead", lastName: "Lovelace" }); + + const response = await h.patch(TARGET_ID, { headline: "", lastName: null }); + + expect(response.status).toBe(200); + expect(h.userRow(TARGET_ID)).toMatchObject({ + headline: null, + lastName: null, + }); + }); + + it("refuses a phone number with letters in it", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { phone: "call me maybe" }); + + expect(response.status).toBe(400); + expect(h.userRow(TARGET_ID)?.phone).toBeUndefined(); + }); + + it("stores a birthday at UTC midnight and clears it with null", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + expect((await h.patch(TARGET_ID, { birthday: "1990-04-21" })).status).toBe( + 200, + ); + expect(h.userRow(TARGET_ID)?.birthday).toEqual( + new Date("1990-04-21T00:00:00.000Z"), + ); + + expect((await h.patch(TARGET_ID, { birthday: null })).status).toBe(200); + expect(h.userRow(TARGET_ID)?.birthday).toBeNull(); + }); + + it("refuses a birthday that is not a real calendar date", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { birthday: "2023-02-30" }); + + expect(response.status).toBe(400); + }); + + it("switches to an installed language", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { language: "pl" }); + + expect(response.status).toBe(200); + expect(h.userRow(TARGET_ID)?.language).toBe("pl"); + }); + + it("refuses a language that is not installed", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { language: "xx" }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid language" }); + expect(h.userRow(TARGET_ID)?.language).toBeUndefined(); + }); + + it("saves a time zone and resets it to automatic with null", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + await h.patch(TARGET_ID, { timeZone: "Europe/Warsaw" }); + expect(h.userRow(TARGET_ID)?.timeZone).toBe("Europe/Warsaw"); + + expect((await h.patch(TARGET_ID, { timeZone: null })).status).toBe(200); + expect(h.userRow(TARGET_ID)?.timeZone).toBeNull(); + }); + + it("refuses a time zone that does not exist", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + + const response = await h.patch(TARGET_ID, { timeZone: "Mars/Olympus" }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid time zone" }); + }); + + it("toggles the newsletter and drops the member's cached session", async () => { + const h = await harness({ editor: [CAN_EDIT] }); + const key = sessionCacheKey(TARGET_SESSION.token, TARGET_SESSION.deviceId); + await h.cache.setSystem(key, { id: TARGET_ID }); + + const response = await h.patch(TARGET_ID, { newsletter: true }); + + expect(response.status).toBe(200); + expect(h.userRow(TARGET_ID)?.newsletter).toBe(true); + expect(await h.cache.getSystem(key)).toBeNull(); + }); + + it("refuses an editor without users:can_edit", async () => { + const h = await harness({ editor: [permission("can_view")] }); + + const response = await h.patch(TARGET_ID, { headline: "Hacked" }); + + expect(response.status).toBe(403); + expect(h.userRow(TARGET_ID)?.headline).toBeUndefined(); + }); + + it("refuses editing a staff target without users:can_edit_admin", async () => { + const h = await harness({ editor: [CAN_EDIT], target: "moderator" }); + + const response = await h.patch(TARGET_ID, { firstName: "Mallory" }); + + expect(response.status).toBe(403); + expect(h.userRow(TARGET_ID)?.firstName).toBeUndefined(); + expect(h.ssoSourcesOf(TARGET_ID)).toEqual(["firstName", "lastName"]); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts index 87608c407..be20a741b 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts @@ -1,14 +1,25 @@ import { z } from "@hono/zod-openapi"; import { and, eq, inArray, ne } from "drizzle-orm"; +import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; import { buildRoute } from "@/api/lib/route"; import { invalidateStaffPermissionsForUser } from "@/api/lib/staff-permission-cache"; import { matchesEmail } from "@/api/lib/user-email-lookup"; import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; +import { SsoConnectionModel } from "@/api/models/sso-connection"; import { CONFIG_PLUGIN } from "@/config"; +import { core_languages } from "@/database/languages"; import { core_roles } from "@/database/roles"; import { core_users, core_users_secondary_roles } from "@/database/users"; import { canonicalizeEmail } from "@/lib/email-canonical"; +import { + personalInformationChanges, + USER_FIRST_NAME_MAX_LENGTH, + USER_HEADLINE_MAX_LENGTH, + USER_LAST_NAME_MAX_LENGTH, + USER_PHONE_MAX_LENGTH, + USER_PHONE_PATTERN, +} from "@/lib/user-personal-information"; import { assertCanAssignRoles, @@ -16,6 +27,21 @@ import { } from "../lib/assert-edit-user-permission"; const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u; +const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +const nullableText = (max: number) => z.string().max(max).nullable(); + +const utcMidnightOf = (date: string): Date => new Date(`${date}T00:00:00.000Z`); + +const isCalendarDate = (value: string): boolean => { + if (!CALENDAR_DATE_PATTERN.test(value)) return false; + + const date = utcMidnightOf(value); + + return ( + !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value + ); +}; export const zodUpdateUserAdminSchema = z .object({ @@ -39,6 +65,34 @@ export const zodUpdateUserAdminSchema = z secondaryRoleIds: z .array(z.number().int().positive()) .openapi({ example: [2, 3] }), + firstName: nullableText(USER_FIRST_NAME_MAX_LENGTH).openapi({ + example: "Emirhan", + }), + lastName: nullableText(USER_LAST_NAME_MAX_LENGTH).openapi({ + example: "Boruch", + }), + phone: z + .string() + .max(USER_PHONE_MAX_LENGTH) + .refine(value => value === "" || USER_PHONE_PATTERN.test(value), { + message: "Invalid phone number", + }) + .nullable() + .openapi({ example: "+48 600 700 800" }), + headline: nullableText(USER_HEADLINE_MAX_LENGTH).openapi({ + example: "Team Manager", + }), + showRealName: z.boolean().openapi({ example: true }), + birthday: z + .string() + .refine(isCalendarDate, { message: "Invalid birthday" }) + .nullable() + .openapi({ example: "1990-04-21" }), + language: z.string().min(1).max(32).openapi({ example: "en" }), + timeZone: z.string().max(64).nullable().openapi({ + example: "Europe/Warsaw", + }), + newsletter: z.boolean().openapi({ example: false }), }) .partial() .refine(body => Object.values(body).some(value => value !== undefined), { @@ -50,7 +104,8 @@ export const updateUserAdminRoute = buildRoute({ adminStaffPermission: { module: "users", permission: "can_edit" }, route: { method: "patch", - description: "Update a user's name or email by id (Admin only)", + description: + "Update a user's account, roles, personal information and preferences by id (Admin only)", path: "/{id}", request: { params: z.object({ @@ -85,7 +140,7 @@ export const updateUserAdminRoute = buildRoute({ schema: z.object({ error: z.string() }), }, }, - description: "Invalid role", + description: "Invalid role, language or time zone", }, 403: { description: "Access Denied", @@ -181,6 +236,40 @@ export const updateUserAdminRoute = buildRoute({ values.nameCode = body.nameCode; } + if (body.language !== undefined) { + const [language] = await db + .select({ code: core_languages.code }) + .from(core_languages) + .where(eq(core_languages.code, body.language)) + .limit(1); + + if (!language) { + return c.json({ error: "Invalid language" }, 400); + } + + values.language = language.code; + } + + if (body.timeZone !== undefined) { + if (body.timeZone !== null && !isValidTimeZone(body.timeZone)) { + return c.json({ error: "Invalid time zone" }, 400); + } + + values.timeZone = body.timeZone; + } + + if (body.birthday !== undefined) { + values.birthday = + body.birthday === null ? null : utcMidnightOf(body.birthday); + } + + if (body.newsletter !== undefined) { + values.newsletter = body.newsletter; + } + + const personalValues = personalInformationChanges(body); + Object.assign(values, personalValues); + const effectivePrimaryId = body.roleId ?? user.roleId; const secondaryRoleIds = body.secondaryRoleIds !== undefined @@ -254,6 +343,13 @@ export const updateUserAdminRoute = buildRoute({ nameCode: core_users.nameCode, }); + await new SsoConnectionModel(c).clearSourcesAfterManualEdit({ + fields: (["firstName", "lastName"] as const).filter( + field => field in personalValues, + ), + userId: user.id, + }); + if (rolesChanged) { // Both caches, not just the permission one. `resolveStaffPermissions` // reads the primary role off the *cached user object*, so recomputing @@ -263,6 +359,8 @@ export const updateUserAdminRoute = buildRoute({ invalidateStaffPermissionsForUser(c, user.id), invalidateSessionCacheForUser(c, user.id), ]); + } else { + await invalidateSessionCacheForUser(c, user.id); } await c.get("events").emit("user.updated", { diff --git a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts index 16b494656..abc6635fa 100644 --- a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts @@ -2,10 +2,22 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; import { createUserAdminRoute } from "./routes/create.route"; +import { listUserDevicesAdminRoute } from "./routes/devices.route"; import { deleteUserImageAdminRoute } from "./routes/image-delete.route"; import { uploadUserImageAdminRoute } from "./routes/image-upload.route"; import { listUsersAdminRoute } from "./routes/list.route"; +import { updateUserNotificationPreferencesAdminRoute } from "./routes/notification-preferences-update.route"; +import { listUserNotificationPreferencesAdminRoute } from "./routes/notification-preferences.route"; +import { deleteUserPasskeyAdminRoute } from "./routes/passkey-delete.route"; +import { renameUserPasskeyAdminRoute } from "./routes/passkey-rename.route"; +import { listUserPasskeysAdminRoute } from "./routes/passkeys.route"; +import { setPasswordUserAdminRoute } from "./routes/password.route"; +import { revokeUserDeviceAdminRoute } from "./routes/revoke-device.route"; +import { revokeUserDevicesAdminRoute } from "./routes/revoke-devices.route"; import { showUserAdminRoute } from "./routes/show.route"; +import { disconnectUserSsoAdminRoute } from "./routes/sso-disconnect.route"; +import { updateUserSsoPreferencesAdminRoute } from "./routes/sso-preferences.route"; +import { listUserSsoAdminRoute } from "./routes/sso.route"; import { timelineUserAdminRoute } from "./routes/timeline.route"; import { updateUserAdminRoute } from "./routes/update.route"; import { verifyEmailUserAdminRoute } from "./routes/verify-email.route"; @@ -20,6 +32,18 @@ export const usersAdminModule = buildModule({ timelineUserAdminRoute, updateUserAdminRoute, verifyEmailUserAdminRoute, + setPasswordUserAdminRoute, + listUserDevicesAdminRoute, + revokeUserDeviceAdminRoute, + revokeUserDevicesAdminRoute, + listUserPasskeysAdminRoute, + renameUserPasskeyAdminRoute, + deleteUserPasskeyAdminRoute, + listUserSsoAdminRoute, + disconnectUserSsoAdminRoute, + updateUserSsoPreferencesAdminRoute, + listUserNotificationPreferencesAdminRoute, + updateUserNotificationPreferencesAdminRoute, uploadUserImageAdminRoute, deleteUserImageAdminRoute, ], diff --git a/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts index cdcfb6c0a..1370a4f3a 100644 --- a/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts +++ b/packages/vitnode/src/api/modules/notifications/routes/preferences.route.ts @@ -13,7 +13,7 @@ import { zodNotificationEmailMode, } from "../schema"; -const zodPreferences = z.object({ +export const zodNotificationPreferences = z.object({ types: z.array( z.object({ category: z.string(), @@ -46,7 +46,7 @@ export const getNotificationPreferencesRoute = buildRoute({ path: "/preferences", responses: { 200: { - content: { "application/json": { schema: zodPreferences } }, + content: { "application/json": { schema: zodNotificationPreferences } }, description: "Notification preferences", }, ...unauthorizedResponse, @@ -64,7 +64,7 @@ export const getNotificationPreferencesRoute = buildRoute({ }, }); -const zodUpdateNotificationPreferences = z.object({ +export const zodUpdateNotificationPreferences = z.object({ types: z .record( z.string().max(100), diff --git a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts index 539d0963c..86e50d623 100644 --- a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts @@ -86,6 +86,7 @@ export const changePasswordRoute = buildRoute({ .delete(core_users_forgot_password) .where(eq(core_users_forgot_password.id, user.id)), ]); + await c.get("events").emit("user.password.updated", { userId }); // After the new password is in place, so a failure above cannot sign // somebody out without having changed anything. Whoever reset this password diff --git a/packages/vitnode/src/api/modules/users/routes/devices.route.ts b/packages/vitnode/src/api/modules/users/routes/devices.route.ts index 10d2c7c60..d5aab7f27 100644 --- a/packages/vitnode/src/api/modules/users/routes/devices.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/devices.route.ts @@ -1,86 +1,13 @@ -import type { Context } from "hono"; - -import { and, eq, gt, inArray } from "drizzle-orm"; import { getCookie } from "hono/cookie"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import { buildRoute } from "@/api/lib/route"; -import { CONFIG_PLUGIN } from "@/config"; -import { core_admin_sessions } from "@/database/admins"; import { - core_sessions, - core_sessions_known_devices, -} from "@/database/sessions"; -import { parseUserAgent } from "@/lib/api/parse-user-agent"; - -const SESSION_KINDS = ["user", "admin"] as const; -type SessionKind = (typeof SESSION_KINDS)[number]; - -interface ActiveSession { - deviceId: number; - expiresAt: Date; - kind: SessionKind; -} - -interface DeviceSessions { - expiresAt: Date; - kinds: Set; -} - -const activeSessionsOf = async ( - c: Context, - userId: number, -): Promise => { - const db = c.get("db"); - const now = new Date(); - - const [userSessions, adminSessions] = await Promise.all([ - db - .select({ - deviceId: core_sessions.deviceId, - expiresAt: core_sessions.expiresAt, - }) - .from(core_sessions) - .where( - and(eq(core_sessions.userId, userId), gt(core_sessions.expiresAt, now)), - ), - db - .select({ - deviceId: core_admin_sessions.deviceId, - expiresAt: core_admin_sessions.expiresAt, - }) - .from(core_admin_sessions) - .where( - and( - eq(core_admin_sessions.userId, userId), - gt(core_admin_sessions.expiresAt, now), - ), - ), - ]); - - return [ - ...userSessions.map(session => ({ ...session, kind: "user" as const })), - ...adminSessions.map(session => ({ ...session, kind: "admin" as const })), - ]; -}; - -const groupByDevice = ( - sessions: ActiveSession[], -): Map => { - const byDevice = new Map(); - for (const { deviceId, expiresAt, kind } of sessions) { - const existing = byDevice.get(deviceId); - if (!existing) { - byDevice.set(deviceId, { expiresAt, kinds: new Set([kind]) }); - continue; - } - existing.kinds.add(kind); - if (expiresAt > existing.expiresAt) existing.expiresAt = expiresAt; - } - - return byDevice; -}; + listUserDevices, + zodUserDeviceSchema, +} from "@/api/models/user-devices"; +import { CONFIG_PLUGIN } from "@/config"; export const listDevicesRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -95,17 +22,7 @@ export const listDevicesRoute = buildRoute({ "application/json": { schema: z.object({ devices: z.array( - z.object({ - publicId: z.string(), - ipAddress: z.string(), - os: z.string(), - browser: z.string(), - deviceType: z.enum(["desktop", "tablet", "mobile"]), - lastSeen: z.date(), - expiresAt: z.date(), - isCurrent: z.boolean(), - sessionKinds: z.array(z.enum(SESSION_KINDS)), - }), + zodUserDeviceSchema.extend({ isCurrent: z.boolean() }), ), }), }, @@ -128,45 +45,13 @@ export const listDevicesRoute = buildRoute({ c.get("core").authorization.deviceCookieName, ); - const sessionsByDevice = groupByDevice(await activeSessionsOf(c, user.id)); - if (sessionsByDevice.size === 0) { - return c.json({ devices: [] }); - } - - const rows = await c - .get("db") - .select({ - id: core_sessions_known_devices.id, - publicId: core_sessions_known_devices.publicId, - ipAddress: core_sessions_known_devices.ipAddress, - userAgent: core_sessions_known_devices.userAgent, - lastSeen: core_sessions_known_devices.lastSeen, - }) - .from(core_sessions_known_devices) - .where( - inArray(core_sessions_known_devices.id, [...sessionsByDevice.keys()]), - ); - - const devices = rows - .flatMap(({ id, userAgent, publicId, ...device }) => { - const sessions = sessionsByDevice.get(id); - if (!sessions) return []; - - return [ - { - ...device, - publicId, - ...parseUserAgent(userAgent), - expiresAt: sessions.expiresAt, - isCurrent: publicId === currentPublicId, - sessionKinds: SESSION_KINDS.filter(kind => - sessions.kinds.has(kind), - ), - }, - ]; - }) - .sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()); + const devices = await listUserDevices(c, user.id); - return c.json({ devices }); + return c.json({ + devices: devices.map(device => ({ + ...device, + isCurrent: device.publicId === currentPublicId, + })), + }); }, }); diff --git a/packages/vitnode/src/api/modules/users/routes/revoke-device.route.ts b/packages/vitnode/src/api/modules/users/routes/revoke-device.route.ts index f8512bcca..9ae8f0ac1 100644 --- a/packages/vitnode/src/api/modules/users/routes/revoke-device.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/revoke-device.route.ts @@ -1,50 +1,11 @@ -import type { Context } from "hono"; - import { z } from "@hono/zod-openapi"; -import { and, eq } from "drizzle-orm"; import { getCookie } from "hono/cookie"; import { HTTPException } from "hono/http-exception"; import { buildRoute } from "@/api/lib/route"; import { revokeSessions } from "@/api/models/session-revoke"; +import { findUserDeviceId } from "@/api/models/user-devices"; import { CONFIG_PLUGIN } from "@/config"; -import { core_admin_sessions } from "@/database/admins"; -import { - core_sessions, - core_sessions_known_devices, -} from "@/database/sessions"; - -const hasSessionOn = async ( - c: Context, - { deviceId, userId }: { deviceId: number; userId: number }, -): Promise => { - const db = c.get("db"); - - const [userSessions, adminSessions] = await Promise.all([ - db - .select({ deviceId: core_sessions.deviceId }) - .from(core_sessions) - .where( - and( - eq(core_sessions.userId, userId), - eq(core_sessions.deviceId, deviceId), - ), - ) - .limit(1), - db - .select({ deviceId: core_admin_sessions.deviceId }) - .from(core_admin_sessions) - .where( - and( - eq(core_admin_sessions.userId, userId), - eq(core_admin_sessions.deviceId, deviceId), - ), - ) - .limit(1), - ]); - - return userSessions.length > 0 || adminSessions.length > 0; -}; export const revokeDeviceRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -97,20 +58,12 @@ export const revokeDeviceRoute = buildRoute({ return c.json({ error: "Cannot revoke the current device" }, 400); } - const db = c.get("db"); - const [device] = await db - .select({ id: core_sessions_known_devices.id }) - .from(core_sessions_known_devices) - .where(eq(core_sessions_known_devices.publicId, publicId)); - - if ( - !device || - !(await hasSessionOn(c, { deviceId: device.id, userId: user.id })) - ) { + const deviceId = await findUserDeviceId(c, { publicId, userId: user.id }); + if (deviceId === null) { return c.json({ error: "Device not found" }, 404); } - await revokeSessions(c, { deviceId: device.id, userId: user.id }); + await revokeSessions(c, { deviceId, userId: user.id }); return c.body(null, 200); }, diff --git a/packages/vitnode/src/components/date-format.tsx b/packages/vitnode/src/components/date-format.tsx index 584e015b2..9baa79c7f 100644 --- a/packages/vitnode/src/components/date-format.tsx +++ b/packages/vitnode/src/components/date-format.tsx @@ -37,7 +37,9 @@ export const DateFormat = ({ if (now.getTime() - dateToFormat.getTime() < 604800000) { return ( - {format.relativeTime(dateToFormat, now)} + + {format.relativeTime(dateToFormat, now)} + ); } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 124d3be8e..922b5eec0 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1911,6 +1911,156 @@ "uploaded": "Cover image updated.", "removed": "Cover image removed." } + }, + "verify": { + "action": "Mark email as verified", + "success": "Email marked as verified", + "successDesc": "{name} can now receive emails from the site." + }, + "badges": { + "staff": "Staff", + "verified": "Verified", + "unverified": "Unverified" + }, + "tabs": { + "profileLabel": "Member profile", + "detailsLabel": "Member details", + "activity": "Activity", + "notifications": "Notifications", + "security": "Password & passkeys", + "securityShort": "Security" + }, + "personal": { + "title": "Personal information", + "edit": "Edit personal information", + "editDesc": "Optional details from the member's profile. Empty fields are cleared.", + "save": "Save changes", + "saved": "Personal information saved", + "savedDesc": "{name}'s profile shows the new details.", + "firstName": "First name", + "lastName": "Last name", + "headline": "Headline", + "headlineDesc": "Shown under the name on the profile. Up to {max} characters.", + "phone": "Phone", + "birthday": "Birthday", + "showRealName": "Show real name", + "showRealNameDesc": "Shows first and last name next to the display name.", + "memberId": "Member ID", + "realName": "Real name", + "hidden": "hidden", + "notSet": "Not set" + }, + "preferences": { + "title": "Preferences", + "edit": "Edit preferences", + "editDesc": "Language, time zone and newsletter for this member.", + "save": "Save changes", + "saved": "Preferences saved", + "savedDesc": "{name} sees the change on their next page load.", + "language": "Language", + "timeZone": "Time zone", + "timeZoneDesc": "Used for digest emails and dates. Automatic follows the language.", + "timeZoneAuto": "Automatic", + "newsletter": "Newsletter", + "newsletterDesc": "Receives newsletter emails sent from the AdminCP.", + "subscribed": "Subscribed", + "notSubscribed": "Not subscribed" + }, + "sso": { + "title": "Connected accounts", + "edit": "Edit profile source and sync", + "editTitle": "Profile source and sync", + "editDesc": "Choose where {name}'s avatar and name come from, and whether they refresh each time they sign in.", + "syncTitle": "Sync on sign-in", + "syncFooter": "Sync refreshes the fields above from that account each time they sign in with it.", + "syncNoFields": "Not a source for any field yet", + "syncOn": "{provider} syncs on sign-in", + "syncOff": "{provider} sync turned off", + "disconnect": "Disconnect {provider}", + "disconnectTitle": "Disconnect {provider}?", + "disconnectDesc": "{name} will no longer be able to sign in with {provider}. Their password and other connections stay as they are.", + "disconnectSubmit": "Disconnect", + "disconnected": "{provider} disconnected", + "disconnectedDesc": "{name} can't sign in with it any more.", + "connected": "Connected", + "sourceBadge": "{fields} from here", + "syncsBadge": "Syncs on sign-in", + "lastMethod": "Can't remove the last way to sign in", + "lastMethodDesc": "Set a password or add another sign-in method first.", + "loadError": "Connected accounts couldn't be loaded. Refresh to try again.", + "empty": "No connected accounts. This member signs in with their password or a passkey." + }, + "devices": { + "title": "Devices", + "deviceName": "{browser} on {os}", + "unknownDevice": "Unknown device", + "signOut": "Sign out this device", + "signOutLabel": "Sign out {device}", + "signOutTitle": "Sign out this device?", + "signOutDesc": "{name} is signed out on {device}, including any AdminCP session there.", + "signOutSubmit": "Sign out", + "signedOut": "Device signed out", + "signedOutDesc": "{device} needs to sign in again.", + "signOutAll": "Sign out all", + "signOutAllTitle": "Sign {name} out everywhere?", + "signOutAllDesc": "{count, plural, one {The session on # device ends} other {Every session on all # devices ends}}, including AdminCP sessions. They can sign in again right away.", + "signedOutAll": "Signed out everywhere", + "signedOutAllDesc": "{name} needs to sign in again on every device.", + "admincp": "AdminCP", + "showAll": "Show all {count} devices", + "showFewer": "Show fewer", + "loadError": "Devices couldn't be loaded. Refresh to try again.", + "empty": "Not signed in on any device." + }, + "security": { + "title": "Password & passkeys", + "password": "Password", + "passwordSet": "Password set", + "passwordNotSet": "No password", + "passwordDisabled": "Password sign-in is turned off on this site.", + "passwordLoadError": "The password status couldn't be loaded. Refresh to try again.", + "passwordChange": "Change", + "passwordAdd": "Set password", + "passwordTitle": "Set a new password", + "passwordDesc": "{name} is signed out of every device and must use the new password next time.", + "newPassword": "New password", + "confirmPassword": "Confirm password", + "passwordMin": "Use at least {min} characters.", + "passwordMismatch": "Passwords don't match.", + "passwordSubmit": "Set password", + "passwordSaved": "Password changed", + "passwordSavedDesc": "{name} was signed out everywhere.", + "passkeys": "Passkeys", + "passkeysEmpty": "No passkeys yet.", + "passkeysDisabled": "Passkeys are turned off on this site.", + "passkeysLoadError": "Passkeys couldn't be loaded. Refresh to try again.", + "passkeySynced": "Synced", + "passkeyDeviceOnly": "This device only", + "passkeyUsed": "Used", + "passkeyNeverUsed": "Never used", + "passkeyRename": "Rename passkey", + "passkeyRenameLabel": "Rename {name}", + "passkeyRenameTitle": "Rename passkey", + "passkeyRenameDesc": "Only the label changes; the passkey keeps working.", + "passkeyName": "Name", + "passkeyRenameSubmit": "Save name", + "passkeyRenamed": "Passkey renamed", + "passkeyDelete": "Delete passkey", + "passkeyDeleteLabel": "Delete {name}", + "passkeyDeleteTitle": "Delete this passkey?", + "passkeyDeleteDesc": "{name} won't be able to sign in with “{passkey}” any more.", + "passkeyDeleteSubmit": "Delete passkey", + "passkeyDeleted": "Passkey deleted", + "passkeyDeletedDesc": "“{passkey}” no longer signs in to this account.", + "lastMethod": "Can't remove the last way to sign in", + "lastMethodDesc": "Set a password or connect another sign-in method first." + }, + "notifications": { + "desc": "Each type follows the site's notification settings until you change it here. Changes apply to {name} only.", + "empty": "No notification types are turned on for this site.", + "saved": "Notification preference saved", + "savedDesc": "Applies to {name} only.", + "loadError": "Notification preferences couldn't be loaded. Refresh to try again." } }, "verify_email": { diff --git a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx index 9706cc55e..9d8e5847b 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx @@ -4,6 +4,10 @@ import type { PluginRouteTranslator } from "@/routing"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import { ADMIN_USER_PERMISSIONS } from "@/views/admin/views/core/shared/admin-permissions"; +import { + adminUserDevicesQueryOptions, + adminUserSsoQueryOptions, +} from "@/views/admin/views/core/users/detail/user-account-query"; import { normalizeAdminUserId } from "@/views/admin/views/core/users/detail/user-query"; import type { AdminScreenContext } from "../screen"; @@ -14,6 +18,8 @@ import { adminUserQuery } from "./query"; export const ADMIN_USER_NAMESPACES = [ "admin.user", + "core.auth.settings.notifications", + "core.auth.settings.sso", "core.global", "core.search", ] as const; @@ -48,10 +54,25 @@ export const loadAdminUserRoute = async ({ const adminUserId = adminIdentityOf(adminAccess); - const user = await queryClient.query({ - ...adminUserQuery({ adminUserId, id }), - staleTime: "static", - }); + const userId = Number(id); + const [user] = await Promise.all([ + queryClient.query({ + ...adminUserQuery({ adminUserId, id }), + staleTime: "static", + }), + queryClient + .query({ + ...adminUserDevicesQueryOptions({ adminUserId, userId }), + staleTime: "static", + }) + .catch(() => null), + queryClient + .query({ + ...adminUserSsoQueryOptions({ adminUserId, userId }), + staleTime: "static", + }) + .catch(() => null), + ]); return { adminUserId, diff --git a/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx b/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx index 5d7b85eb0..dfe2ae402 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx @@ -5,8 +5,12 @@ import { useTranslations } from "use-intl"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import { useAdminStaffPermissions } from "@/components/staff-permission/provider"; +import { UserConnectedAccountsCard } from "@/views/admin/views/core/users/detail/user-connected-accounts"; import { UserDetailContent } from "@/views/admin/views/core/users/detail/user-detail-content"; +import { UserDevicesCard } from "@/views/admin/views/core/users/detail/user-devices"; +import { UserNotificationsPanel } from "@/views/admin/views/core/users/detail/user-notifications"; import { canEditAdminUser } from "@/views/admin/views/core/users/detail/user-query"; +import { UserSecurityPanel } from "@/views/admin/views/core/users/detail/user-security"; import { adminUserTimelineQueryOptions } from "@/views/admin/views/core/users/detail/user-timeline-query"; import { searchAdminRolesInBrowser } from "@/views/admin/views/core/users/roles/roles-query"; import { SearchFeedList } from "@/views/search/search-feed-content"; @@ -38,14 +42,41 @@ const UserTimeline = ({ const AdminUserScreen = ({ adminUserId, id, locale }: AdminUserRouteProps) => { const t = useTranslations("admin.user.show.images"); const { data: user } = useSuspenseQuery(adminUserQuery({ adminUserId, id })); - const { onRemoveImage, onUpdate, onUpdateRoles, onUploadImage } = - useAdminUserMutations(); + const { + onRemoveImage, + onUpdate, + onUpdateRoles, + onUploadImage, + onVerifyEmail, + } = useAdminUserMutations(); const permissions = useAdminStaffPermissions(); + const canEdit = canEditAdminUser(permissions, user); return ( -
+
+ } + devices={ + + } + notifications={ + + } onRemoveImage={async (userId, kind) => { await onRemoveImage(userId, kind); toast.success(t(`${kind}.removed`), { @@ -60,7 +91,15 @@ const AdminUserScreen = ({ adminUserId, id, locale }: AdminUserRouteProps) => { description: t("uploadedDesc"), }); }} + onVerifyEmail={onVerifyEmail} searchRoles={searchAdminRolesInBrowser} + security={ + + } timeline={ = {}, +): AdminUserDetail => ({ + avatarColor: "#123456", + avatarUrl: null, + birthday: null, + coverUrl: null, + createdAt: "2026-01-01T00:00:00.000Z", + email: "moderator@example.com", + emailVerified: true, + firstName: null, + headline: null, + id: 7, + imagePolicy: { + avatar: { allowed: true, maxBytes: 1_000_000 }, + cover: { allowed: true, maxBytes: 1_000_000 }, + }, + isStaff: false, + language: "en", + lastName: null, + name: "Moderator", + nameCode: "moderator", + newsletter: false, + phone: null, + role: MEMBER_ROLE, + roleId: MEMBER_ROLE.id, + secondaryRoles: [], + showRealName: false, + timeZone: null, + ...overrides, +}); diff --git a/packages/vitnode/src/tests/memory-db.ts b/packages/vitnode/src/tests/memory-db.ts index 3ca798475..fc5a51d64 100644 --- a/packages/vitnode/src/tests/memory-db.ts +++ b/packages/vitnode/src/tests/memory-db.ts @@ -417,6 +417,9 @@ const evaluate = ( if (["bigint", "int", "integer", "numeric"].includes(node.to)) { return Number(value); } + if (node.to === "jsonb") { + return typeof value === "string" ? JSON.parse(value) : value; + } return unsupported(`a cast to ${node.to}`); } @@ -895,6 +898,7 @@ export const createMemoryDb = (seed: [Table, readonly object[]][] = []) => { const query = { ...thenable(run), + for: () => query, from: (from: Table) => { source = from; diff --git a/packages/vitnode/src/tests/sessions.ts b/packages/vitnode/src/tests/sessions.ts index a31019b18..aa076b95e 100644 --- a/packages/vitnode/src/tests/sessions.ts +++ b/packages/vitnode/src/tests/sessions.ts @@ -2,6 +2,7 @@ import type { Table } from "drizzle-orm"; import type { Context, Next } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; +import { vi } from "vitest"; import type { EnvVariablesVitNode, @@ -175,6 +176,7 @@ export const createSessionWorld = async ({ }; }; + const emit = vi.fn(async () => Promise.resolve(undefined)); const app = new OpenAPIHono(); app.use("*", async (c: Context, next: Next) => { c.set("core", { @@ -182,6 +184,7 @@ export const createSessionWorld = async ({ } as unknown as EnvVariablesVitNode["core"]); c.set("db", db as unknown as EnvVariablesVitNode["db"]); c.set("cache", cache); + c.set("events", { emit } as unknown as EnvVariablesVitNode["events"]); c.set("ipAddress", "203.0.113.7"); c.set("user", await new SessionModel(c).getUser()); await next(); @@ -219,5 +222,5 @@ export const createSessionWorld = async ({ }; }; - return { app, cache, isCached, probe, rows, rowsFor }; + return { app, cache, emit, isCached, probe, rows, rowsFor }; }; diff --git a/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts b/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts index 7de42ff66..afdc3d832 100644 --- a/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts +++ b/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts @@ -67,13 +67,25 @@ const key = ({ }): string => `${module}:${permission}`; describe("the scan is reading real declarations", () => { - it("finds the eight users routes that declare one", () => { - expect([...declarationsIn("users").keys()]).toEqual([ + it("finds the twenty users routes that declare one", () => { + expect([...declarationsIn("users").keys()].sort()).toEqual([ "create.route.ts", + "devices.route.ts", "image-delete.route.ts", "image-upload.route.ts", "list.route.ts", + "notification-preferences-update.route.ts", + "notification-preferences.route.ts", + "passkey-delete.route.ts", + "passkey-rename.route.ts", + "passkeys.route.ts", + "password.route.ts", + "revoke-device.route.ts", + "revoke-devices.route.ts", "show.route.ts", + "sso-disconnect.route.ts", + "sso-preferences.route.ts", + "sso.route.ts", "timeline.route.ts", "update.route.ts", "verify-email.route.ts", diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/edit-sheet-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/edit-sheet-content.tsx new file mode 100644 index 000000000..a08059bf8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/edit-sheet-content.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import { + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; + +export const EditSheetContent = ({ + children, + description, + title, +}: { + children: React.ReactNode; + description: React.ReactNode; + title: React.ReactNode; +}) => ( + + + {title} + {description} + +
+ + +
+ } + > + {children} + +
+ +); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/name-code-form.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/name-code-form.tsx new file mode 100644 index 000000000..3039f92b8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/name-code-form.tsx @@ -0,0 +1,98 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { useDialog } from "@/components/ui/dialog"; +import { setFormFieldError } from "@/components/ui/form"; + +import type { UpdateAdminUser } from "./user-fields-content"; + +export const NameCodeForm = ({ + id, + nameCode, + onUpdate, +}: { + id: number; + nameCode: string; + onUpdate: UpdateAdminUser; +}) => { + const t = useTranslations("admin.user.show"); + const tError = useTranslations("core.global.errors"); + const { setIsDirty, setOpen } = useDialog(); + + const formSchema = z.object({ + currentNameCode: z + .string({ message: tError("field_required") }) + .refine(value => value === nameCode, t("nameCodeConfirmMismatch")) + .default(""), + newNameCode: z + .string({ message: tError("field_required") }) + .min(3, tError("field_min_length", { min: 3 })) + .max(255) + .regex(/^[a-zA-Z0-9-]+$/, t("nameCodeInvalid")) + .refine(value => value !== nameCode, t("nameCodeSame")) + .default(""), + }); + + const onSubmit: AutoFormOnSubmit = async ( + values, + form, + ) => { + const result = await onUpdate(id, { nameCode: values.newNameCode }); + + if ("data" in result) { + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("updateSuccess")); + + return; + } + + if (result.error.status === 409) { + setFormFieldError(form, "newNameCode", t("nameCodeExists")); + + return; + } + + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; + + return ( + ( + {chunks}, + nameCode: () => {nameCode}, + })} + {...props} + /> + ), + id: "currentNameCode", + }, + { + component: props => ( + + ), + id: "newNameCode", + }, + ]} + formSchema={formSchema} + mode="all" + onSubmit={onSubmit} + submitButtonProps={{ + children: t("saveNameCode"), + variant: "destructive", + }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/use-failure-toast.ts b/packages/vitnode/src/views/admin/views/core/users/detail/use-failure-toast.ts new file mode 100644 index 000000000..3f75f574c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/use-failure-toast.ts @@ -0,0 +1,12 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +export const useFailureToast = () => { + const tError = useTranslations("core.global.errors"); + + return () => { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-account-mutations.ts b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-mutations.ts new file mode 100644 index 000000000..96aa3bcd5 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-mutations.ts @@ -0,0 +1,180 @@ +import type { SsoProfileField } from "@/lib/sso-profile"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { CONFIG_PLUGIN } from "@/config"; +import { fetcherClient } from "@/lib/fetcher-client"; +import { + type AdminMutationResult, + runAdminApiMutation, +} from "@/views/admin/views/core/shared/admin-mutation"; + +import type { AdminUserPasskey } from "./user-account-query"; + +const OPTIONS = { credentials: "include" } as const; + +const ok = () => true as const; + +export const setAdminUserPassword = async ( + id: number, + password: string, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { password }, params: { id: String(id) } }, + method: "put", + module: "admin/users", + options: OPTIONS, + path: "/{id}/password", + }), + }); + +export const revokeAdminUserDevice = async ( + id: number, + publicId: string, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + params: { id: String(id), publicId: encodeURIComponent(publicId) }, + }, + method: "delete", + module: "admin/users", + options: OPTIONS, + path: "/{id}/devices/{publicId}", + }), + }); + +export const revokeAdminUserDevices = async ( + id: number, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id: String(id) } }, + method: "delete", + module: "admin/users", + options: OPTIONS, + path: "/{id}/devices", + }), + }); + +export const renameAdminUserPasskey = async ( + id: number, + passkeyId: number, + name: string, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: async response => (await response.json()) as AdminUserPasskey, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + body: { name }, + params: { id: String(id), passkeyId: String(passkeyId) }, + }, + method: "patch", + module: "admin/users", + options: OPTIONS, + path: "/{id}/passkeys/{passkeyId}", + }), + }); + +export const deleteAdminUserPasskey = async ( + id: number, + passkeyId: number, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: { id: String(id), passkeyId: String(passkeyId) } }, + method: "delete", + module: "admin/users", + options: OPTIONS, + path: "/{id}/passkeys/{passkeyId}", + }), + }); + +export const disconnectAdminUserSso = async ( + id: number, + providerId: string, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { + params: { + id: String(id), + providerId: encodeURIComponent(providerId), + }, + }, + method: "delete", + module: "admin/users", + options: OPTIONS, + path: "/{id}/sso/{providerId}", + }), + }); + +export interface AdminUserSsoPreferencesInput { + sources: Partial>; + sync: Record; +} + +export const updateAdminUserSsoPreferences = async ( + id: number, + body: AdminUserSsoPreferencesInput, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: ok, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body, params: { id: String(id) } }, + method: "put", + module: "admin/users", + options: OPTIONS, + path: "/{id}/sso/preferences", + }), + }); + +export type AdminUserNotificationPatch = Partial< + NotificationPreferenceTypeView["value"] +>; + +export const updateAdminUserNotificationPreferences = async ( + id: number, + types: Record, +): Promise> => + await runAdminApiMutation({ + expected: 200, + parse: async response => + ((await response.json()) as { types: NotificationPreferenceTypeView[] }) + .types, + request: async () => + await fetcherClient({ + plugin: CONFIG_PLUGIN.pluginId, + args: { body: { types }, params: { id: String(id) } }, + method: "put", + module: "admin/users", + options: OPTIONS, + path: "/{id}/notification-preferences", + }), + }); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx new file mode 100644 index 000000000..7dc4f7733 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx @@ -0,0 +1,364 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SsoConnectionsApi } from "@/views/auth/settings/sso/sso-connections-query"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { adminUserFixture } from "@/tests/admin-user"; + +import type { AdminUserDevice } from "./user-account-query"; + +import { + adminUserDevicesQueryKey, + adminUserNotificationsQueryKey, + adminUserSsoQueryKey, +} from "./user-account-query"; +import { UserConnectedAccountsCard } from "./user-connected-accounts"; +import { UserDevicesCard } from "./user-devices"; +import { UserNotificationsPanel } from "./user-notifications"; +import { UserSecurityPanel } from "./user-security"; + +const ADMIN_ID = 1; +const USER_ID = 7; +const KEY = { adminUserId: ADMIN_ID, userId: USER_ID }; + +const user = adminUserFixture({ id: USER_ID, name: "Target" }); + +const device = (publicId: string, browser: string): AdminUserDevice => ({ + browser, + deviceType: "desktop", + expiresAt: "2026-12-01T00:00:00.000Z", + ipAddress: "10.0.0.1", + lastSeen: "2026-10-01T00:00:00.000Z", + os: "macOS", + publicId, + sessionKinds: ["user"], +}); + +const sso = ( + hasPassword: boolean, + signIn: Partial = {}, +): SsoConnectionsApi => ({ + providers: [ + { + available: true, + connection: { + accountLabel: "octocat", + connectedAt: "2026-03-01T00:00:00.000Z", + email: null, + syncOnSignIn: false, + }, + icon: null, + id: "github", + name: "GitHub", + profileFields: ["avatar"], + }, + ], + signIn: { + hasPassword, + passkeys: 0, + passkeysEnabled: true, + passwordEnabled: true, + ...signIn, + }, + sources: { avatar: null, firstName: null, lastName: null }, +}); + +const notificationType: NotificationPreferenceTypeView = { + category: "account", + categoryLabel: "Account", + defaultEmail: "immediate", + description: null, + emailModes: ["none", "immediate", "daily", "weekly"], + id: "core.example", + inAppAvailable: true, + label: "Example type", + locked: false, + mandatory: false, + pluginId: "@vitnode/core", + pushAvailable: false, + value: { email: "immediate", inApp: true, push: false }, +}; + +const json = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status: 200, + }); + +const mount = (ui: React.ReactNode, seed: (client: QueryClient) => void) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + seed(queryClient); + + render( + + {ui} + , + ); +}; + +const fetchMock = vi.fn(); + +const requestOf = (index: number) => { + const [input, init] = fetchMock.mock.calls[index] ?? []; + + return { + body: + typeof init?.body === "string" + ? (JSON.parse(init.body) as unknown) + : undefined, + method: init?.method, + url: + input instanceof Request + ? input.url + : input instanceof URL + ? input.href + : (input ?? ""), + }; +}; + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + addEventListener: vi.fn(), + matches: false, + removeEventListener: vi.fn(), + })), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("UserDevicesCard", () => { + const devices = [ + device("a1", "Chrome"), + device("b2", "Safari"), + device("c3", "Firefox"), + device("d4", "Edge"), + device("e5", "Opera"), + ]; + + it("lists the first three devices until all are revealed", async () => { + mount( + , + client => { + client.setQueryData(adminUserDevicesQueryKey(KEY), devices); + }, + ); + + expect( + await screen.findAllByText("admin.user.show.devices.deviceName"), + ).toHaveLength(3); + + fireEvent.click( + screen.getByRole("button", { name: "admin.user.show.devices.showAll" }), + ); + + expect( + screen.getAllByText("admin.user.show.devices.deviceName"), + ).toHaveLength(5); + }); + + it("signs one device out after the confirmation", async () => { + fetchMock.mockImplementation( + async (_input, init) => + await Promise.resolve( + init?.method === "DELETE" + ? json({ ok: true }) + : json({ devices: devices.slice(1) }), + ), + ); + mount( + , + client => { + client.setQueryData(adminUserDevicesQueryKey(KEY), devices); + }, + ); + + const [first] = await screen.findAllByRole("button", { + name: "admin.user.show.devices.signOutLabel", + }); + if (!first) throw new Error("No sign-out button"); + fireEvent.click(first); + fireEvent.click( + await screen.findByText("admin.user.show.devices.signOutSubmit"), + ); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + const request = requestOf(0); + expect(request.method).toBe("DELETE"); + expect(request.url).toContain(`/${USER_ID}/devices/a1`); + }); + + it("has no sign-out controls without edit permission", async () => { + mount( + , + client => { + client.setQueryData(adminUserDevicesQueryKey(KEY), devices); + }, + ); + + await screen.findAllByText("admin.user.show.devices.deviceName"); + expect(screen.queryByText("admin.user.show.devices.signOutAll")).toBeNull(); + expect( + screen.queryByRole("button", { + name: "admin.user.show.devices.signOutLabel", + }), + ).toBeNull(); + }); +}); + +describe("UserConnectedAccountsCard", () => { + const disconnect = async () => + screen.findByRole("button", { name: "admin.user.show.sso.disconnect" }); + + it("blocks disconnecting the only way to sign in", async () => { + mount( + , + client => { + client.setQueryData(adminUserSsoQueryKey(KEY), sso(false)); + }, + ); + + expect((await disconnect()).hasAttribute("disabled")).toBe(true); + }); + + it("disconnects an account when the member still has a password", async () => { + fetchMock.mockImplementation( + async (_input, init) => + await Promise.resolve( + init?.method === "DELETE" ? json({ ok: true }) : json(sso(true)), + ), + ); + mount( + , + client => { + client.setQueryData(adminUserSsoQueryKey(KEY), sso(true)); + }, + ); + + const button = await disconnect(); + expect(button.hasAttribute("disabled")).toBe(false); + fireEvent.click(button); + fireEvent.click( + await screen.findByText("admin.user.show.sso.disconnectSubmit"), + ); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + const request = requestOf(0); + expect(request.method).toBe("DELETE"); + expect(request.url).toContain(`/${USER_ID}/sso/github`); + }); +}); + +describe("UserSecurityPanel", () => { + const panel = ( + + ); + + it("reports a failed sign-in lookup instead of loading passkeys forever", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 500 })); + mount(panel, () => {}); + + expect( + await screen.findByText("admin.user.show.security.passkeysLoadError"), + ).not.toBeNull(); + expect( + screen.getByText("admin.user.show.security.passwordLoadError"), + ).not.toBeNull(); + expect( + screen.queryByRole("button", { + name: "admin.user.show.security.passwordAdd", + }), + ).toBeNull(); + }); + + it("offers no password reset while password sign-in is turned off", async () => { + mount(panel, client => { + client.setQueryData( + adminUserSsoQueryKey(KEY), + sso(false, { passkeysEnabled: false, passwordEnabled: false }), + ); + }); + + expect( + await screen.findByText("admin.user.show.security.passwordDisabled"), + ).not.toBeNull(); + expect( + screen.queryByRole("button", { + name: "admin.user.show.security.passwordAdd", + }), + ).toBeNull(); + }); + + it("offers a password reset while password sign-in is on", async () => { + mount(panel, client => { + client.setQueryData( + adminUserSsoQueryKey(KEY), + sso(false, { passkeysEnabled: false }), + ); + }); + + expect( + await screen.findByRole("button", { + name: "admin.user.show.security.passwordAdd", + }), + ).not.toBeNull(); + }); +}); + +describe("UserNotificationsPanel", () => { + it("saves a channel change for the member", async () => { + fetchMock.mockImplementation( + async () => + await Promise.resolve( + json({ + types: [ + { + ...notificationType, + value: { ...notificationType.value, inApp: false }, + }, + ], + }), + ), + ); + mount( + , + client => { + client.setQueryData(adminUserNotificationsQueryKey(KEY), [ + notificationType, + ]); + }, + ); + + fireEvent.click( + await screen.findByRole("button", { name: "Example type" }), + ); + const [inApp] = screen.getAllByRole("switch"); + if (!inApp) throw new Error("No in-app switch"); + fireEvent.click(inApp); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + const request = requestOf(0); + expect(request.method).toBe("PUT"); + expect(request.url).toContain(`/${USER_ID}/notification-preferences`); + expect(request.body).toEqual({ + types: { "core.example": { inApp: false } }, + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-account-query.ts b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-query.ts new file mode 100644 index 000000000..db9c123b9 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-query.ts @@ -0,0 +1,181 @@ +import { queryOptions } from "@tanstack/react-query"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import type { SsoConnectionsApi } from "@/views/auth/settings/sso/sso-connections-query"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { CONFIG_PLUGIN } from "@/config"; +import { RECORD_STALE_TIME } from "@/lib/query-freshness"; +import { fetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; + +import { adminUserQueryKey } from "./user-query"; + +export interface AdminUserDevice { + browser: null | string; + deviceType: "desktop" | "mobile" | "tablet"; + expiresAt: Date | string; + ipAddress: string; + lastSeen: Date | string; + os: null | string; + publicId: string; + sessionKinds: ("admin" | "user")[]; +} + +export interface AdminUserPasskey { + backedUp: boolean; + createdAt: Date | string; + deviceType: "multiDevice" | "singleDevice"; + id: number; + lastUsedAt: Date | null | string; + name: string; + transports: string[]; +} + +interface AdminUserAccountKey { + adminUserId: AdminIdentity; + userId: number; +} + +const accountKey = ( + { adminUserId, userId }: AdminUserAccountKey, + part: string, +) => [...adminUserQueryKey({ adminUserId, id: String(userId) }), part] as const; + +export const adminUserDevicesQueryKey = (key: AdminUserAccountKey) => + accountKey(key, "devices"); + +export const adminUserPasskeysQueryKey = (key: AdminUserAccountKey) => + accountKey(key, "passkeys"); + +export const adminUserSsoQueryKey = (key: AdminUserAccountKey) => + accountKey(key, "sso"); + +export const adminUserNotificationsQueryKey = (key: AdminUserAccountKey) => + accountKey(key, "notifications"); + +const params = (userId: number) => ({ id: String(userId) }); + +export const fetchAdminUserDevices = async ( + userId: number, +): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: params(userId) }, + method: "get", + module: "admin/users", + path: "/{id}/devices", + }); + if (!response.ok) { + throw new AdminRequestError( + response.status, + "a user's devices", + `id=${userId}`, + ); + } + + return (await response.json()).devices; +}; + +export const fetchAdminUserPasskeys = async ( + userId: number, +): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: params(userId) }, + method: "get", + module: "admin/users", + path: "/{id}/passkeys", + }); + if (!response.ok) { + throw new AdminRequestError( + response.status, + "a user's passkeys", + `id=${userId}`, + ); + } + + return (await response.json()).items; +}; + +export const fetchAdminUserSso = async ( + userId: number, +): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: params(userId) }, + method: "get", + module: "admin/users", + path: "/{id}/sso", + }); + if (!response.ok) { + throw new AdminRequestError( + response.status, + "a user's connected accounts", + `id=${userId}`, + ); + } + + return await response.json(); +}; + +export const fetchAdminUserNotificationPreferences = async ( + userId: number, +): Promise => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + args: { params: params(userId) }, + method: "get", + module: "admin/users", + path: "/{id}/notification-preferences", + }); + if (!response.ok) { + throw new AdminRequestError( + response.status, + "a user's notification preferences", + `id=${userId}`, + ); + } + + return (await response.json()).types; +}; + +export const adminUserDevicesQueryOptions = (key: AdminUserAccountKey) => + queryOptions({ + queryFn: async () => await fetchAdminUserDevices(key.userId), + queryKey: adminUserDevicesQueryKey(key), + staleTime: RECORD_STALE_TIME, + }); + +export const adminUserPasskeysQueryOptions = (key: AdminUserAccountKey) => + queryOptions({ + queryFn: async () => await fetchAdminUserPasskeys(key.userId), + queryKey: adminUserPasskeysQueryKey(key), + staleTime: RECORD_STALE_TIME, + }); + +export const adminUserSsoQueryOptions = (key: AdminUserAccountKey) => + queryOptions({ + queryFn: async () => await fetchAdminUserSso(key.userId), + queryKey: adminUserSsoQueryKey(key), + staleTime: RECORD_STALE_TIME, + }); + +export const adminUserNotificationsQueryOptions = (key: AdminUserAccountKey) => + queryOptions({ + queryFn: async () => + await fetchAdminUserNotificationPreferences(key.userId), + queryKey: adminUserNotificationsQueryKey(key), + staleTime: RECORD_STALE_TIME, + }); + +export const countSignInMethods = ({ + passkeys, + sso, +}: { + passkeys: number; + sso: SsoConnectionsApi | undefined; +}): number => + (sso?.signIn.hasPassword ? 1 : 0) + + passkeys + + (sso?.providers.filter(provider => provider.connection).length ?? 0); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-connected-accounts.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-connected-accounts.tsx new file mode 100644 index 000000000..dcbb6b184 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-connected-accounts.tsx @@ -0,0 +1,410 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + KeyRoundIcon, + RefreshCwIcon, + Settings2Icon, + UnplugIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import type { SsoConnectionsApi } from "@/views/auth/settings/sso/sso-connections-query"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardHeader, +} from "@/components/ui/card"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipWithContent, +} from "@/components/ui/tooltip"; +import { SSO_PROFILE_FIELDS } from "@/lib/sso-profile"; +import { + SETTINGS_ROW, + SettingsGroup, +} from "@/views/auth/settings/settings-group"; +import { SsoFieldsGroup } from "@/views/auth/settings/sso/sso-fields-group"; +import { SsoProviderMark } from "@/views/auth/settings/sso/sso-provider-badge"; +import { normalizeSSOProviders } from "@/views/auth/sso/providers"; + +import type { AdminUserDetail } from "./user-query"; + +import { EditSheetContent } from "./edit-sheet-content"; +import { useFailureToast } from "./use-failure-toast"; +import { + disconnectAdminUserSso, + updateAdminUserSsoPreferences, +} from "./user-account-mutations"; +import { + adminUserSsoQueryOptions, + countSignInMethods, +} from "./user-account-query"; +import { DetailCardTitle } from "./user-profile-cards"; +import { adminUserQueryKey } from "./user-query"; + +type Provider = SsoConnectionsApi["providers"][number]; + +type ConnectedProvider = Provider & { + connection: NonNullable; +}; + +const connectedProviders = (data: SsoConnectionsApi): ConnectedProvider[] => + data.providers.filter( + (provider): provider is ConnectedProvider => provider.connection !== null, + ); + +const sourcedFields = (data: SsoConnectionsApi, providerId: string) => + SSO_PROFILE_FIELDS.filter(field => data.sources[field] === providerId); + +const SyncSwitch = ({ + onToggle, + provider, +}: { + onToggle: (checked: boolean) => Promise; + provider: ConnectedProvider; +}) => { + const tSso = useTranslations("core.auth.settings.sso"); + const [isSaving, setIsSaving] = React.useState(false); + + return ( + { + setIsSaving(true); + try { + await onToggle(checked); + } finally { + setIsSaving(false); + } + }} + /> + ); +}; + +const EditSsoDialog = ({ + data, + onSaved, + user, +}: { + data: SsoConnectionsApi; + onSaved: () => Promise; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.sso"); + const tSso = useTranslations("core.auth.settings.sso"); + const showFailure = useFailureToast(); + const connected = connectedProviders(data); + const views = normalizeSSOProviders( + data.providers.map(({ icon, id, name }) => ({ icon, id, name })), + ); + + return ( + + + + } + > + + + + +
+ { + const result = await updateAdminUserSsoPreferences(user.id, body); + if ("error" in result) { + return { + failure: + result.error.status === 400 + ? "invalid_source" + : "server_error", + ok: false, + }; + } + await onSaved(); + + return { ok: true }; + }} + profile={{ + avatarUrl: user.avatarUrl, + firstName: user.firstName, + lastName: user.lastName, + }} + /> + + {connected.map(provider => { + const fields = sourcedFields(data, provider.id); + + return ( +
  • + view.id === provider.id) ?? { + id: provider.id, + name: provider.name, + } + } + /> +
    + + {provider.name} + + + {fields.length > 0 + ? fields + .map(field => tSso(`fields.${field}`)) + .join(", ") + : t("syncNoFields")} + +
    + { + const result = await updateAdminUserSsoPreferences( + user.id, + { sources: {}, sync: { [provider.id]: checked } }, + ); + if ("error" in result) { + showFailure(); + + return; + } + await onSaved(); + toast.success( + checked + ? t("syncOn", { provider: provider.name }) + : t("syncOff", { provider: provider.name }), + ); + }} + provider={provider} + /> +
  • + ); + })} +
    +
    +
    +
    + ); +}; + +const DisconnectButton = ({ + disabledReason, + onDisconnect, + provider, + userName, +}: { + disabledReason?: string; + onDisconnect: () => Promise; + provider: Provider; + userName: string; +}) => { + const t = useTranslations("admin.user.show.sso"); + const label = t("disconnect", { provider: provider.name }); + + if (disabledReason) { + return ( + + + + + + ); + } + + return ( + + } + onSubmit={async ({ onClose }) => { + await onDisconnect(); + onClose(); + }} + textSubmit={t("disconnectSubmit")} + title={t("disconnectTitle", { provider: provider.name })} + > + } + > + + + + {label} + + ); +}; + +export const UserConnectedAccountsCard = ({ + adminUserId, + canEdit, + user, +}: { + adminUserId: AdminIdentity; + canEdit: boolean; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.sso"); + const tSso = useTranslations("core.auth.settings.sso"); + const showFailure = useFailureToast(); + const queryClient = useQueryClient(); + const key = { adminUserId, userId: user.id }; + const { data, isError, isPending } = useQuery(adminUserSsoQueryOptions(key)); + + const refresh = async () => { + await queryClient.invalidateQueries({ + queryKey: adminUserQueryKey({ adminUserId, id: String(user.id) }), + }); + }; + + const connected = data ? connectedProviders(data) : []; + const views = normalizeSSOProviders( + (data?.providers ?? []).map(({ icon, id, name }) => ({ icon, id, name })), + ); + const isLastMethod = + countSignInMethods({ passkeys: data?.signIn.passkeys ?? 0, sso: data }) <= + 1; + + return ( + + + {t("title")} + {canEdit && data && connected.length > 0 && ( + + + + )} + + + {isPending ? ( +
    + + +
    + ) : isError ? ( +

    + {t("loadError")} +

    + ) : connected.length === 0 ? ( +

    + {t("empty")} +

    + ) : ( +
      + {connected.map(provider => { + const fields = sourcedFields(data, provider.id); + const { connection } = provider; + + return ( +
    • + view.id === provider.id) ?? { + id: provider.id, + name: provider.name, + } + } + /> +
      + + + {connection.accountLabel ?? provider.name} + + + {provider.name} + {connection.email ? ` · ${connection.email}` : ""} + + + {t("connected")}{" "} + + + + {(fields.length > 0 || connection.syncOnSignIn) && ( + + {fields.length > 0 && ( + + {t("sourceBadge", { + fields: fields + .map(field => tSso(`fields.${field}`)) + .join(", "), + })} + + )} + {connection.syncOnSignIn && ( + + + {t("syncsBadge")} + + )} + + )} +
      + {canEdit && ( + { + const result = await disconnectAdminUserSso( + user.id, + provider.id, + ); + if ("error" in result) { + if (result.error.status === 409) { + toast.error(t("lastMethod"), { + description: t("lastMethodDesc"), + }); + } else { + showFailure(); + } + + return; + } + await refresh(); + toast.success( + t("disconnected", { provider: provider.name }), + { + description: t("disconnectedDesc", { + name: user.name, + }), + }, + ); + }} + provider={provider} + userName={user.name} + /> + )} +
    • + ); + })} +
    + )} +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx index 1b0b9738d..7554a5b73 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx @@ -4,12 +4,14 @@ import { createRouter, RouterProvider, } from "@tanstack/react-router"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { StaffPermissionSet } from "@/api/lib/permission-staff"; +import { adminUserFixture } from "@/tests/admin-user"; + import type { AdminUserDetail } from "./user-query"; import { UserDetailContent } from "./user-detail-content"; @@ -24,31 +26,6 @@ const permissionSet = (...permissions: string[]): StaffPermissionSet => ({ root: false, }); -const MEMBER_ROLE = { color: null, id: 3, name: [] }; - -const userFixture = (isStaff: boolean): AdminUserDetail => ({ - avatarColor: "#123456", - avatarUrl: null, - birthday: null, - coverUrl: null, - createdAt: "2026-01-01T00:00:00.000Z", - email: "moderator@example.com", - emailVerified: true, - id: 7, - imagePolicy: { - avatar: { allowed: true, maxBytes: 1_000_000 }, - cover: { allowed: true, maxBytes: 1_000_000 }, - }, - isStaff, - language: "en", - name: "Moderator", - nameCode: "moderator", - newsletter: false, - role: MEMBER_ROLE, - roleId: MEMBER_ROLE.id, - secondaryRoles: [], -}); - const mount = async ({ permissions, user, @@ -60,12 +37,17 @@ const mount = async ({ component: () => ( connected accounts slot

    } + devices={

    devices slot

    } + notifications={

    notifications slot

    } onRemoveImage={vi.fn()} onUpdate={vi.fn()} onUpdateRoles={vi.fn()} onUploadImage={vi.fn()} + onVerifyEmail={vi.fn()} searchRoles={vi.fn()} - timeline={null} + security={

    security slot

    } + timeline={

    timeline slot

    } user={user} /> ), @@ -88,6 +70,8 @@ const editControls = () => [ screen.queryByRole("button", { name: "admin.user.show.editName" }), screen.queryByRole("button", { name: "admin.user.show.editEmail" }), screen.queryByRole("button", { name: "admin.user.show.editRoles" }), + screen.queryByRole("button", { name: "admin.user.show.personal.edit" }), + screen.queryByRole("button", { name: "admin.user.show.preferences.edit" }), ]; describe("UserDetailContent edit controls for a staff target", () => { @@ -117,7 +101,7 @@ describe("UserDetailContent edit controls for a staff target", () => { it("hides them for a moderator when the viewer lacks users:can_edit_admin", async () => { await mount({ permissions: permissionSet("can_edit"), - user: userFixture(true), + user: adminUserFixture({ isStaff: true }), }); for (const control of editControls()) { @@ -128,7 +112,7 @@ describe("UserDetailContent edit controls for a staff target", () => { it("shows them for a moderator when the viewer holds users:can_edit_admin", async () => { await mount({ permissions: permissionSet("can_edit", "can_edit_admin"), - user: userFixture(true), + user: adminUserFixture({ isStaff: true }), }); for (const control of editControls()) { @@ -139,11 +123,56 @@ describe("UserDetailContent edit controls for a staff target", () => { it("shows them for a member with users:can_edit alone", async () => { await mount({ permissions: permissionSet("can_edit"), - user: userFixture(false), + user: adminUserFixture(), }); for (const control of editControls()) { expect(control).not.toBeNull(); } }); + + it("offers to verify the email only while it is unverified", async () => { + await mount({ + permissions: permissionSet("can_edit"), + user: adminUserFixture({ emailVerified: false }), + }); + + expect( + screen.getByRole("button", { name: "admin.user.show.verify.action" }), + ).not.toBeNull(); + expect( + screen.getByText("admin.user.show.badges.unverified"), + ).not.toBeNull(); + }); + + it("does not offer to verify an already verified email", async () => { + await mount({ + permissions: permissionSet("can_edit"), + user: adminUserFixture(), + }); + + expect( + screen.queryByRole("button", { name: "admin.user.show.verify.action" }), + ).toBeNull(); + }); + + it("shows the activity tab first and switches to the notifications tab", async () => { + await mount({ + permissions: permissionSet("can_edit"), + user: adminUserFixture(), + }); + + expect(screen.getByText("timeline slot")).not.toBeNull(); + expect(screen.getByText("connected accounts slot")).not.toBeNull(); + expect(screen.getByText("devices slot")).not.toBeNull(); + expect(screen.queryByText("notifications slot")).toBeNull(); + + fireEvent.click( + screen.getByRole("tab", { + name: /admin\.user\.show\.tabs\.notifications/, + }), + ); + + expect(await screen.findByText("notifications slot")).not.toBeNull(); + }); }); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx index 1d3ad51b3..91325becc 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx @@ -1,13 +1,9 @@ -import { Link } from "@tanstack/react-router"; -import { ExternalLinkIcon } from "lucide-react"; +import { BellIcon, HistoryIcon, LockIcon } from "lucide-react"; import { useTranslations } from "use-intl"; +import type { VerifyAdminUserEmail } from "@/views/admin/views/core/users/list/users-table-content"; import type { AdminRoleSearch } from "@/views/admin/views/core/users/roles/roles-query"; -import { Avatar } from "@/components/avatar"; -import { DateFormat } from "@/components/date-format"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, @@ -15,7 +11,6 @@ import { TabsPanels, TabsTrigger, } from "@/components/ui/tabs"; -import { UserCoverImage } from "@/components/user-cover-image"; import type { UpdateAdminUser } from "./user-fields-content"; import type { @@ -25,165 +20,100 @@ import type { import type { AdminUserDetail } from "./user-query"; import type { UpdateAdminUserRoles } from "./user-roles-content"; -import { - EditNameCodeContent, - EditUserFieldContent, -} from "./user-fields-content"; -import { AdminUserImageDialog } from "./user-images-content"; +import { UserIdentityCard } from "./user-identity-card"; +import { UserPersonalCard, UserPreferencesCard } from "./user-profile-cards"; import { UserRolesCardContent } from "./user-roles-content"; export interface UserDetailProps { canEdit: boolean; + connectedAccounts: React.ReactNode; + devices: React.ReactNode; + notifications: React.ReactNode; onRemoveImage: RemoveAdminUserImage; onUpdate: UpdateAdminUser; onUpdateRoles: UpdateAdminUserRoles; onUploadImage: UploadAdminUserImage; + onVerifyEmail: VerifyAdminUserEmail; searchRoles: AdminRoleSearch; + security: React.ReactNode; timeline: React.ReactNode; user: AdminUserDetail; } export const UserDetailContent = ({ canEdit, + connectedAccounts, + devices, + notifications, onRemoveImage, onUpdate, onUpdateRoles, onUploadImage, + onVerifyEmail, searchRoles, + security, timeline, user, }: UserDetailProps) => { - const t = useTranslations("admin.user.show"); - const tSearch = useTranslations("core.search"); + const t = useTranslations("admin.user.show.tabs"); return ( - - - - {tSearch("userTab.overview")} - - - {tSearch("userTab.timeline")} - - - - - -
    - -
    - - {user.coverUrl ? null : ( - {t("coverPlaceholder")} - )} - {canEdit && ( -
    - -
    - )} -
    - - -
    -
    - - {canEdit && ( -
    - -
    - )} -
    -
    - - - -
    - - @{user.nameCode} - - {canEdit && ( - - )} -
    - -
    - -
    - -

    - {t("joined")} -

    - -
    - -
    -
    -
    - - -
    -
    - - {timeline} -
    -
    +
    + + + +

    {t("detailsLabel")}

    +
    + + + + {t("activity")} + + + + {t("notifications")} + + + + {t("securityShort")} + {t("security")} + + +
    + + {timeline} + {notifications} + {security} + +
    +
    ); }; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-devices.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-devices.tsx new file mode 100644 index 000000000..419038660 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-devices.tsx @@ -0,0 +1,315 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + LaptopIcon, + LogOutIcon, + MonitorSmartphoneIcon, + SmartphoneIcon, + TabletIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardHeader, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import type { AdminUserDevice } from "./user-account-query"; +import type { AdminUserDetail } from "./user-query"; + +import { useFailureToast } from "./use-failure-toast"; +import { + revokeAdminUserDevice, + revokeAdminUserDevices, +} from "./user-account-mutations"; +import { + adminUserDevicesQueryKey, + adminUserDevicesQueryOptions, +} from "./user-account-query"; +import { DetailCardTitle } from "./user-profile-cards"; + +const VISIBLE_DEVICES = 3; + +const DEVICE_ICONS = { + desktop: LaptopIcon, + mobile: SmartphoneIcon, + tablet: TabletIcon, +}; + +const useDeviceName = () => { + const t = useTranslations("admin.user.show.devices"); + + return (device: AdminUserDevice) => + device.browser && device.os + ? t("deviceName", { browser: device.browser, os: device.os }) + : (device.browser ?? device.os ?? t("unknownDevice")); +}; + +const RevokeDeviceButton = ({ + device, + onRevoke, + userName, +}: { + device: AdminUserDevice; + onRevoke: () => Promise; + userName: string; +}) => { + const t = useTranslations("admin.user.show.devices"); + const deviceName = useDeviceName()(device); + const label = t("signOutLabel", { device: deviceName }); + + return ( + + } + onSubmit={async ({ onClose }) => { + await onRevoke(); + onClose(); + }} + textSubmit={t("signOutSubmit")} + title={t("signOutTitle")} + > + } + > + + + + {t("signOut")} + + ); +}; + +const DeviceRow = ({ + canEdit, + device, + onRevoke, + userName, +}: { + canEdit: boolean; + device: AdminUserDevice; + onRevoke: () => Promise; + userName: string; +}) => { + const t = useTranslations("admin.user.show.devices"); + const deviceName = useDeviceName(); + const Icon = DEVICE_ICONS[device.deviceType]; + + return ( +
  • + + + +
    + + + {deviceName(device)} + + {device.sessionKinds.includes("admin") && ( + {t("admincp")} + )} + + + {device.ipAddress} + {" · "} + + +
    + {canEdit && ( + + )} +
  • + ); +}; + +const SignOutAllButton = ({ + count, + onSignOutAll, + userName, +}: { + count: number; + onSignOutAll: () => Promise; + userName: string; +}) => { + const t = useTranslations("admin.user.show.devices"); + + return ( + } + onSubmit={async ({ onClose }) => { + if (await onSignOutAll()) onClose(); + }} + textSubmit={t("signOutAll")} + title={t("signOutAllTitle", { name: userName })} + > + + + ); +}; + +const DeviceList = ({ + canEdit, + devices, + onRevoke, + userName, +}: { + canEdit: boolean; + devices: AdminUserDevice[]; + onRevoke: (device: AdminUserDevice) => Promise; + userName: string; +}) => { + const t = useTranslations("admin.user.show.devices"); + const [showAll, setShowAll] = React.useState(false); + + return ( + <> +
      + {(showAll ? devices : devices.slice(0, VISIBLE_DEVICES)).map(device => ( + { + await onRevoke(device); + }} + userName={userName} + /> + ))} +
    + {devices.length > VISIBLE_DEVICES && ( + + )} + + ); +}; + +export const UserDevicesCard = ({ + adminUserId, + canEdit, + user, +}: { + adminUserId: AdminIdentity; + canEdit: boolean; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.devices"); + const queryClient = useQueryClient(); + const deviceName = useDeviceName(); + const showFailure = useFailureToast(); + const key = { adminUserId, userId: user.id }; + const { + data: devices, + isError, + isPending, + } = useQuery(adminUserDevicesQueryOptions(key)); + + const refresh = async () => { + await queryClient.invalidateQueries({ + queryKey: adminUserDevicesQueryKey(key), + }); + }; + + const revokeDevice = async (device: AdminUserDevice) => { + const result = await revokeAdminUserDevice(user.id, device.publicId); + if ("error" in result) { + showFailure(); + + return; + } + await refresh(); + toast.success(t("signedOut"), { + description: t("signedOutDesc", { device: deviceName(device) }), + }); + }; + + const signOutAll = async () => { + const result = await revokeAdminUserDevices(user.id); + if ("error" in result) { + showFailure(); + + return false; + } + await refresh(); + toast.success(t("signedOutAll"), { + description: t("signedOutAllDesc", { name: user.name }), + }); + + return true; + }; + + return ( + + + + {t("title")} + + {canEdit && devices && devices.length > 0 && ( + + + + )} + + + {isPending ? ( +
    + + + +
    + ) : isError ? ( +

    + {t("loadError")} +

    + ) : devices.length === 0 ? ( +

    + {t("empty")} +

    + ) : ( + + )} +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx index 40a09f495..8370e2eea 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx @@ -2,29 +2,18 @@ import { CheckIcon, LinkIcon, MailIcon, PencilIcon, XIcon } from "lucide-react"; import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; -import { z } from "zod"; -import type { AutoFormOnSubmit } from "@/components/form/auto-form"; import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation"; import type { AdminUserUpdateInput } from "@/views/admin/views/core/users/users-mutations"; -import { AutoForm } from "@/components/form/auto-form"; -import { AutoFormInput } from "@/components/form/fields/input"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, - useDialog, -} from "@/components/ui/dialog"; -import { setFormFieldError } from "@/components/ui/form"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { TooltipWithContent } from "@/components/ui/tooltip"; +import { EditSheetContent } from "./edit-sheet-content"; + /** How the page performs a user update. Supplied by whichever app mounts this. */ export type UpdateAdminUser = ( id: number, @@ -119,26 +108,30 @@ export const EditUserFieldContent = ({ type={type} value={draft} /> - - + + + + + + ); } @@ -154,107 +147,29 @@ export const EditUserFieldContent = ({ )}
    {canEdit && ( - + + + )} ); }; -const NameCodeForm = ({ - id, - nameCode, - onUpdate, -}: { - id: number; - nameCode: string; - onUpdate: UpdateAdminUser; -}) => { - const t = useTranslations("admin.user.show"); - const tError = useTranslations("core.global.errors"); - const { setIsDirty, setOpen } = useDialog(); - - const formSchema = z.object({ - currentNameCode: z - .string({ message: tError("field_required") }) - .refine(value => value === nameCode, t("nameCodeConfirmMismatch")) - .default(""), - newNameCode: z - .string({ message: tError("field_required") }) - .min(3, tError("field_min_length", { min: 3 })) - .max(255) - .regex(/^[a-zA-Z0-9-]+$/, t("nameCodeInvalid")) - .refine(value => value !== nameCode, t("nameCodeSame")) - .default(""), - }); - - const onSubmit: AutoFormOnSubmit = async ( - values, - form, - ) => { - const result = await onUpdate(id, { nameCode: values.newNameCode }); - - if ("data" in result) { - setIsDirty?.(false); - setOpen?.(false); - toast.success(t("updateSuccess")); - - return; - } - - if (result.error.status === 409) { - setFormFieldError(form, "newNameCode", t("nameCodeExists")); - - return; - } - - toast.error(tError("title"), { - description: tError("internal_server_error"), - }); - }; - - return ( - ( - {chunks}, - nameCode: () => {nameCode}, - })} - {...props} - /> - ), - id: "currentNameCode", - }, - { - component: props => ( - - ), - id: "newNameCode", - }, - ]} - formSchema={formSchema} - mode="all" - onSubmit={onSubmit} - submitButtonProps={{ - children: t("saveNameCode"), - variant: "destructive", - }} - /> - ); -}; +const NameCodeForm = React.lazy(async () => + import("./name-code-form").then(module => ({ + default: module.NameCodeForm, + })), +); export const EditNameCodeContent = ({ id, @@ -269,34 +184,36 @@ export const EditNameCodeContent = ({ return ( - - } - > - - + + + } + > + + + - - - + {t("editNameCode")} - - {t("editNameCodeDesc")} - - + + } + > {t("editNameCodeWarningTitle")} {t("editNameCodeWarning")} - + ); }; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-identity-card.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-identity-card.tsx new file mode 100644 index 000000000..bdebbf67c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-identity-card.tsx @@ -0,0 +1,206 @@ +import { Link } from "@tanstack/react-router"; +import { + BadgeCheckIcon, + ExternalLinkIcon, + MailWarningIcon, + ShieldIcon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { VerifyAdminUserEmail } from "@/views/admin/views/core/users/list/users-table-content"; + +import { Avatar } from "@/components/avatar"; +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { UserCoverImage } from "@/components/user-cover-image"; + +import type { UpdateAdminUser } from "./user-fields-content"; +import type { + RemoveAdminUserImage, + UploadAdminUserImage, +} from "./user-images-content"; +import type { AdminUserDetail } from "./user-query"; + +import { + EditNameCodeContent, + EditUserFieldContent, +} from "./user-fields-content"; +import { AdminUserImageDialog } from "./user-images-content"; + +const VerifyEmailButton = ({ + onVerifyEmail, + user, +}: { + onVerifyEmail: VerifyAdminUserEmail; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.verify"); + const tError = useTranslations("core.global.errors"); + const [isPending, startTransition] = React.useTransition(); + + return ( + + ); +}; + +export const UserIdentityCard = ({ + canEdit, + onRemoveImage, + onUpdate, + onUploadImage, + onVerifyEmail, + user, +}: { + canEdit: boolean; + onRemoveImage: RemoveAdminUserImage; + onUpdate: UpdateAdminUser; + onUploadImage: UploadAdminUserImage; + onVerifyEmail: VerifyAdminUserEmail; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show"); + + return ( + +
    + + {user.coverUrl ? null : ( + {t("coverPlaceholder")} + )} + {canEdit && ( +
    + +
    + )} +
    + + +
    + + {canEdit && ( +
    + +
    + )} +
    + +
    + +
    + + @{user.nameCode} + + {canEdit && ( + + )} +
    +
    + {user.isStaff && ( + + + {t("badges.staff")} + + )} + {user.emailVerified ? ( + + + {t("badges.verified")} + + ) : ( + + + {t("badges.unverified")} + + )} +
    +
    + +
    + +

    + {t("joined")} +

    +
    + +
    + {canEdit && !user.emailVerified && ( + + )} + +
    +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-notifications.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-notifications.tsx new file mode 100644 index 000000000..2a40b980d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-notifications.tsx @@ -0,0 +1,149 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import type { NotificationPreferenceTypeView } from "@/views/notifications/notifications-query"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { NotificationTypeItem } from "@/views/notifications/settings/type-item"; + +import type { AdminUserNotificationPatch } from "./user-account-mutations"; +import type { AdminUserDetail } from "./user-query"; + +import { useFailureToast } from "./use-failure-toast"; +import { updateAdminUserNotificationPreferences } from "./user-account-mutations"; +import { + adminUserNotificationsQueryKey, + adminUserNotificationsQueryOptions, +} from "./user-account-query"; + +interface NotificationGroup { + label: string; + types: NotificationPreferenceTypeView[]; +} + +const groupNotificationTypes = ( + types: NotificationPreferenceTypeView[], +): NotificationGroup[] => [ + ...types + .reduce((map, type) => { + const group = map.get(type.category) ?? { + label: type.categoryLabel, + types: [], + }; + group.types.push(type); + + return map.set(type.category, group); + }, new Map()) + .values(), +]; + +export const UserNotificationsPanel = ({ + adminUserId, + canEdit, + user, +}: { + adminUserId: AdminIdentity; + canEdit: boolean; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.notifications"); + const showFailure = useFailureToast(); + const queryClient = useQueryClient(); + const key = { adminUserId, userId: user.id }; + const queryKey = adminUserNotificationsQueryKey(key); + const { data, isError, isPending } = useQuery( + adminUserNotificationsQueryOptions(key), + ); + const [openId, setOpenId] = React.useState(null); + + const save = async (typeId: string, patch: AdminUserNotificationPatch) => { + const previous = + queryClient.getQueryData(queryKey); + queryClient.setQueryData( + queryKey, + current => + current?.map(type => + type.id === typeId + ? { ...type, value: { ...type.value, ...patch } } + : type, + ), + ); + + const result = await updateAdminUserNotificationPreferences(user.id, { + [typeId]: patch, + }); + + if ("error" in result) { + queryClient.setQueryData(queryKey, previous); + showFailure(); + + return; + } + + queryClient.setQueryData(queryKey, result.data); + toast.success(t("saved"), { + description: t("savedDesc", { name: user.name }), + }); + }; + + if (isPending) { + return ( +
    + + +
    + ); + } + + if (isError) { + return ( +

    + {t("loadError")} +

    + ); + } + + const groups = groupNotificationTypes(data); + + return ( +
    +

    + {t("desc", { name: user.name })} +

    + {groups.length === 0 ? ( +

    {t("empty")}

    + ) : ( +
      + {groups.map(group => ( + + {groups.length > 1 ? ( +
    • +

      {group.label}

      +
    • + ) : null} + {group.types.map(type => ( + { + if (!canEdit) return; + void save(type.id, patch); + }} + onToggle={() => { + setOpenId(current => + current === type.id ? null : type.id, + ); + }} + type={canEdit ? type : { ...type, locked: true }} + /> + ))} +
      + ))} +
    + )} +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-cards.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-cards.tsx new file mode 100644 index 000000000..5d5a5749e --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-cards.tsx @@ -0,0 +1,209 @@ +import { IdCardIcon, PencilIcon, SlidersHorizontalIcon } from "lucide-react"; +import React from "react"; +import { useFormatter, useTranslations } from "use-intl"; + +import { useLanguages } from "@/components/languages-provider"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { timeZoneLabel } from "@/views/auth/settings/overview/time-zone-update"; + +import type { UpdateAdminUser } from "./user-fields-content"; +import type { AdminUserDetail } from "./user-query"; + +import { EditSheetContent } from "./edit-sheet-content"; + +const PersonalForm = React.lazy(async () => + import("./user-profile-forms").then(module => ({ + default: module.PersonalForm, + })), +); + +const PreferencesForm = React.lazy(async () => + import("./user-profile-forms").then(module => ({ + default: module.PreferencesForm, + })), +); + +export const DetailRow = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( +
    +
    {label}
    +
    + {children} +
    +
    +); + +export const DetailCardTitle = ({ + children, + icon: Icon, +}: { + children: React.ReactNode; + icon: React.ElementType<{ className?: string }>; +}) => ( + + + {children} + +); + +const EditGroupDialog = ({ + children, + description, + label, + title, +}: { + children: React.ReactNode; + description: string; + label: string; + title: string; +}) => ( + + + } + > + + + + + {children} + + +); + +export const UserPersonalCard = ({ + canEdit, + onUpdate, + user, +}: { + canEdit: boolean; + onUpdate: UpdateAdminUser; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.personal"); + const format = useFormatter(); + const realName = [user.firstName, user.lastName].filter(Boolean).join(" "); + const notSet = {t("notSet")}; + + return ( + + + {t("title")} + {canEdit && ( + + + + + + )} + + +
    + + #{user.id} + + + {realName ? ( + <> + {realName} + {!user.showRealName && ( + + {" "} + · {t("hidden")} + + )} + + ) : ( + notSet + )} + + {user.headline ?? notSet} + + {user.phone ? ( + {user.phone} + ) : ( + notSet + )} + + + {user.birthday + ? format.dateTime(new Date(user.birthday), { + dateStyle: "long", + timeZone: "UTC", + }) + : notSet} + +
    +
    +
    + ); +}; + +export const UserPreferencesCard = ({ + canEdit, + onUpdate, + user, +}: { + canEdit: boolean; + onUpdate: UpdateAdminUser; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.preferences"); + const languages = useLanguages(); + const language = + languages.find(item => item.code === user.language)?.name ?? user.language; + + return ( + + + + {t("title")} + + {canEdit && ( + + + + + + )} + + +
    + {language} + + {user.timeZone ? ( + timeZoneLabel(user.timeZone) + ) : ( + {t("timeZoneAuto")} + )} + + + {user.newsletter ? t("subscribed") : t("notSubscribed")} + +
    +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-forms.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-forms.tsx new file mode 100644 index 000000000..1e5dfae9a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-profile-forms.tsx @@ -0,0 +1,272 @@ +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormCombobox } from "@/components/form/fields/combobox"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { useLanguages } from "@/components/languages-provider"; +import { useDialog } from "@/components/ui/dialog"; +import { + USER_FIRST_NAME_MAX_LENGTH, + USER_HEADLINE_MAX_LENGTH, + USER_LAST_NAME_MAX_LENGTH, + USER_PHONE_MAX_LENGTH, + USER_PHONE_PATTERN, +} from "@/lib/user-personal-information"; +import { + supportedTimeZones, + timeZoneLabel, +} from "@/views/auth/settings/overview/time-zone-update"; + +import type { UpdateAdminUser } from "./user-fields-content"; +import type { AdminUserDetail } from "./user-query"; + +import { useFailureToast } from "./use-failure-toast"; + +const AUTOMATIC_TIME_ZONE = "auto"; + +const toDateInput = (value: AdminUserDetail["birthday"]): string => { + if (!value) return ""; + const date = new Date(value); + + return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10); +}; + +const emptyToNull = (value: string): null | string => { + const trimmed = value.trim(); + + return trimmed === "" ? null : trimmed; +}; + +export const PersonalForm = ({ + onUpdate, + user, +}: { + onUpdate: UpdateAdminUser; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.personal"); + const tError = useTranslations("core.global.errors"); + const { setIsDirty, setOpen } = useDialog(); + const showFailure = useFailureToast(); + + const formSchema = z.object({ + firstName: z + .string() + .max(USER_FIRST_NAME_MAX_LENGTH) + .default(user.firstName ?? ""), + lastName: z + .string() + .max(USER_LAST_NAME_MAX_LENGTH) + .default(user.lastName ?? ""), + headline: z + .string() + .max(USER_HEADLINE_MAX_LENGTH) + .default(user.headline ?? ""), + phone: z + .string() + .max(USER_PHONE_MAX_LENGTH) + .refine(value => value === "" || USER_PHONE_PATTERN.test(value), { + message: tError("field_invalid_phone"), + }) + .default(user.phone ?? ""), + birthday: z.string().default(toDateInput(user.birthday)), + showRealName: z.boolean().default(user.showRealName), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const result = await onUpdate(user.id, { + birthday: values.birthday === "" ? null : values.birthday, + firstName: emptyToNull(values.firstName), + headline: emptyToNull(values.headline), + lastName: emptyToNull(values.lastName), + phone: emptyToNull(values.phone), + showRealName: values.showRealName, + }); + + if ("error" in result) { + showFailure(); + + return; + } + + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("saved"), { + description: t("savedDesc", { name: user.name }), + }); + }; + + return ( + ( + + ), + }, + { + id: "lastName", + component: props => ( + + ), + }, + { + id: "headline", + component: props => ( + + ), + }, + { + id: "phone", + component: props => ( + + ), + }, + { + id: "birthday", + component: props => ( + + ), + }, + { + id: "showRealName", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("save") }} + /> + ); +}; + +export const PreferencesForm = ({ + onUpdate, + user, +}: { + onUpdate: UpdateAdminUser; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.preferences"); + const { setIsDirty, setOpen } = useDialog(); + const showFailure = useFailureToast(); + const languages = useLanguages(); + const zones = React.useMemo(() => supportedTimeZones(), []); + const options = languages.some(language => language.code === user.language) + ? languages + : [...languages, { code: user.language, name: user.language }]; + const codes = options.map(language => language.code); + const [firstCode = user.language, ...restCodes] = codes; + + const formSchema = z.object({ + language: z.enum([firstCode, ...restCodes]).default(user.language), + timeZone: z + .enum([AUTOMATIC_TIME_ZONE, ...zones]) + .default(user.timeZone ?? AUTOMATIC_TIME_ZONE), + newsletter: z.boolean().default(user.newsletter), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const result = await onUpdate(user.id, { + language: values.language, + newsletter: values.newsletter, + timeZone: + values.timeZone === AUTOMATIC_TIME_ZONE ? null : values.timeZone, + }); + + if ("error" in result) { + showFailure(); + + return; + } + + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("saved"), { + description: t("savedDesc", { name: user.name }), + }); + }; + + return ( + ( + ({ + label: language.name, + value: language.code, + }))} + /> + ), + }, + { + id: "timeZone", + component: props => ( + ({ + label: timeZoneLabel(zone), + value: zone, + })), + ]} + /> + ), + }, + { + id: "newsletter", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ children: t("save") }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts b/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts index 76c3451d1..f3bb1f6e4 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts @@ -37,16 +37,22 @@ export interface AdminUserDetail { createdAt: Date | string; email: string; emailVerified: boolean; + firstName: null | string; + headline: null | string; id: number; imagePolicy: UserImagePolicy; isStaff: boolean; language: string; + lastName: null | string; name: string; nameCode: string; newsletter: boolean; + phone: null | string; role: AdminUserRole; roleId: number; secondaryRoles: AdminUserRole[]; + showRealName: boolean; + timeZone: null | string; } export type AdminUserFetcher = (id: string) => Promise; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx index bea7fab8a..06ad446d2 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx @@ -37,22 +37,16 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - useDialog, -} from "@/components/ui/dialog"; +import { Dialog, DialogTrigger, useDialog } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Spinner } from "@/components/ui/spinner"; +import { TooltipWithContent } from "@/components/ui/tooltip"; + +import { EditSheetContent } from "./edit-sheet-content"; export type UpdateAdminUserRoles = ( id: number, @@ -340,11 +334,11 @@ const EditRolesForm = ({ /> - +
    - +
    ); }; @@ -376,27 +370,29 @@ export const UserRolesCardContent = ({ {canEdit && ( - - } - > - - + + + } + > + + + - - - + {t("editRoles")} - - {t("editRolesDesc")} - - + + } + > - + )} diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-security-forms.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-security-forms.tsx new file mode 100644 index 000000000..39e3a2f42 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-security-forms.tsx @@ -0,0 +1,148 @@ +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { useDialog } from "@/components/ui/dialog"; +import { PASSKEY_NAME_MAX_LENGTH } from "@/lib/passkey"; + +import type { AdminUserPasskey } from "./user-account-query"; +import type { AdminUserDetail } from "./user-query"; + +import { useFailureToast } from "./use-failure-toast"; +import { + renameAdminUserPasskey, + setAdminUserPassword, +} from "./user-account-mutations"; + +const PASSWORD_MIN_LENGTH = 8; + +export const PasswordForm = ({ + onSaved, + user, +}: { + onSaved: () => Promise; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const { setIsDirty, setOpen } = useDialog(); + const showFailure = useFailureToast(); + const formSchema = z + .object({ + password: z + .string() + .min( + PASSWORD_MIN_LENGTH, + t("passwordMin", { min: PASSWORD_MIN_LENGTH }), + ) + .default(""), + confirm: z.string().default(""), + }) + .refine(values => values.password === values.confirm, { + message: t("passwordMismatch"), + path: ["confirm"], + }); + + return ( + ( + + ), + }, + { + id: "confirm", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={async values => { + const result = await setAdminUserPassword(user.id, values.password); + if ("error" in result) { + showFailure(); + + return; + } + await onSaved(); + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("passwordSaved"), { + description: t("passwordSavedDesc", { name: user.name }), + }); + }} + submitButtonProps={{ children: t("passwordSubmit") }} + /> + ); +}; + +export const RenamePasskeyForm = ({ + onSaved, + passkey, + user, +}: { + onSaved: () => Promise; + passkey: AdminUserPasskey; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const { setIsDirty, setOpen } = useDialog(); + const showFailure = useFailureToast(); + const formSchema = z.object({ + name: z + .string() + .trim() + .min(1) + .max(PASSKEY_NAME_MAX_LENGTH) + .default(passkey.name), + }); + + return ( + ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={async values => { + const result = await renameAdminUserPasskey( + user.id, + passkey.id, + values.name, + ); + if ("error" in result) { + showFailure(); + + return; + } + await onSaved(); + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("passkeyRenamed")); + }} + submitButtonProps={{ children: t("passkeyRenameSubmit") }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-security.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-security.tsx new file mode 100644 index 000000000..d1b076c8e --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-security.tsx @@ -0,0 +1,377 @@ +import { + useQuery, + useQueryClient, + type UseQueryResult, +} from "@tanstack/react-query"; +import { + FingerprintIcon, + LockIcon, + PencilIcon, + Trash2Icon, +} from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; +import type { SsoConnectionsApi } from "@/views/auth/settings/sso/sso-connections-query"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { DateFormat } from "@/components/date-format"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipWithContent, +} from "@/components/ui/tooltip"; + +import type { AdminUserPasskey } from "./user-account-query"; +import type { AdminUserDetail } from "./user-query"; + +import { EditSheetContent } from "./edit-sheet-content"; +import { useFailureToast } from "./use-failure-toast"; +import { deleteAdminUserPasskey } from "./user-account-mutations"; +import { + adminUserPasskeysQueryOptions, + adminUserSsoQueryOptions, + countSignInMethods, +} from "./user-account-query"; +import { DetailCardTitle } from "./user-profile-cards"; +import { adminUserQueryKey } from "./user-query"; + +const PasswordForm = React.lazy(async () => + import("./user-security-forms").then(module => ({ + default: module.PasswordForm, + })), +); + +const RenamePasskeyForm = React.lazy(async () => + import("./user-security-forms").then(module => ({ + default: module.RenamePasskeyForm, + })), +); + +const SubHeading = ({ + children, + count, +}: { + children: string; + count?: number; +}) => ( +

    + {children} + {count === undefined ? null : {count}} +

    +); + +const PasskeyRow = ({ + canEdit, + isLastMethod, + onSaved, + passkey, + user, +}: { + canEdit: boolean; + isLastMethod: boolean; + onSaved: () => Promise; + passkey: AdminUserPasskey; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const showFailure = useFailureToast(); + + return ( +
  • + + + +
    + + {passkey.name} + + + {passkey.backedUp ? t("passkeySynced") : t("passkeyDeviceOnly")} + {" · "} + {passkey.lastUsedAt ? ( + <> + {t("passkeyUsed")} + + ) : ( + t("passkeyNeverUsed") + )} + +
    + {canEdit && ( +
    + + + + } + > + + + + + + + + {isLastMethod ? ( + + + + + + ) : ( + + } + onSubmit={async ({ onClose }) => { + const result = await deleteAdminUserPasskey( + user.id, + passkey.id, + ); + if ("error" in result) { + if (result.error.status === 409) { + toast.error(t("lastMethod"), { + description: t("lastMethodDesc"), + }); + } else { + showFailure(); + } + + return; + } + await onSaved(); + toast.success(t("passkeyDeleted"), { + description: t("passkeyDeletedDesc", { + passkey: passkey.name, + }), + }); + onClose(); + }} + textSubmit={t("passkeyDeleteSubmit")} + title={t("passkeyDeleteTitle")} + > + + } + > + + + + {t("passkeyDelete")} + + )} +
    + )} +
  • + ); +}; + +const PasswordSection = ({ + canEdit, + onSaved, + sso, + user, +}: { + canEdit: boolean; + onSaved: () => Promise; + sso: UseQueryResult; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const hasPassword = sso.data?.signIn.hasPassword === true; + + return ( +
    + {t("password")} +
    + + + + + {sso.isPending ? ( + + ) : sso.isError ? ( + + {t("passwordLoadError")} + + ) : !sso.data.signIn.passwordEnabled ? ( + + {t("passwordDisabled")} + + ) : hasPassword ? ( + t("passwordSet") + ) : ( + t("passwordNotSet") + )} + + {canEdit && sso.data?.signIn.passwordEnabled === true && ( + + }> + {hasPassword ? t("passwordChange") : t("passwordAdd")} + + + + + + )} +
    +
    + ); +}; + +const PasskeysSection = ({ + canEdit, + enabled, + isCapabilityError, + isLastMethod, + onSaved, + query, + user, +}: { + canEdit: boolean; + enabled: boolean; + isCapabilityError: boolean; + isLastMethod: boolean; + onSaved: () => Promise; + query: UseQueryResult; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + + return ( +
    + {t("passkeys")} + {!enabled ? ( +

    + {t("passkeysDisabled")} +

    + ) : isCapabilityError || query.isError ? ( +

    + {t("passkeysLoadError")} +

    + ) : query.isPending ? ( +
    + + +
    + ) : query.data.length === 0 ? ( +

    + {t("passkeysEmpty")} +

    + ) : ( +
      + {query.data.map(passkey => ( + + ))} +
    + )} +
    + ); +}; + +export const UserSecurityPanel = ({ + adminUserId, + canEdit, + user, +}: { + adminUserId: AdminIdentity; + canEdit: boolean; + user: AdminUserDetail; +}) => { + const t = useTranslations("admin.user.show.security"); + const queryClient = useQueryClient(); + const key = { adminUserId, userId: user.id }; + const sso = useQuery(adminUserSsoQueryOptions(key)); + const passkeysEnabled = sso.data?.signIn.passkeysEnabled === true; + const passkeys = useQuery({ + ...adminUserPasskeysQueryOptions(key), + enabled: passkeysEnabled, + }); + const isLastMethod = + countSignInMethods({ + passkeys: passkeys.data?.length ?? 0, + sso: sso.data, + }) <= 1; + + const refresh = async () => { + await queryClient.invalidateQueries({ + queryKey: adminUserQueryKey({ adminUserId, id: String(user.id) }), + }); + }; + + return ( + + + {t("title")} + + + + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts b/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts index 64226d046..8984c101c 100644 --- a/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts @@ -9,11 +9,20 @@ import { } from "@/views/admin/views/core/shared/admin-mutation"; export interface AdminUserUpdateInput { + birthday?: null | string; email?: string; + firstName?: null | string; + headline?: null | string; + language?: string; + lastName?: null | string; name?: string; nameCode?: string; + newsletter?: boolean; + phone?: null | string; roleId?: number; secondaryRoleIds?: number[]; + showRealName?: boolean; + timeZone?: null | string; } export interface AdminUserUpdated { diff --git a/packages/vitnode/src/views/profile/images/user-image-dialog.tsx b/packages/vitnode/src/views/profile/images/user-image-dialog.tsx index a4295c3a2..e2bf655e4 100644 --- a/packages/vitnode/src/views/profile/images/user-image-dialog.tsx +++ b/packages/vitnode/src/views/profile/images/user-image-dialog.tsx @@ -11,6 +11,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; +import { TooltipWithContent } from "@/components/ui/tooltip"; import type { UserImageDialogContentProps, @@ -52,18 +53,20 @@ export const UserImageDialog = ({ return ( - - } - > - - + + + } + > + + +