diff --git a/.env.example b/.env.example index 415063ee7..6063dc176 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,3 @@ -CLIENT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -TENANT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" APP_URL="http://localhost:3000" BACKEND_URL="http://localhost:3002" -API_URI="api://xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/access_as_user" -STUDENT_USERNAME_PATTERN="@edu" DEFAULT_TEST_USER="adam.admin@gbsl.ch" \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 433e5004e..24fd3ea03 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,9 +21,6 @@ jobs: cache: yarn - name: Install dependencies and build website env: - CLIENT_ID: ${{ secrets.CLIENT_ID }} - TENANT_ID: ${{ secrets.TENANT_ID }} - API_URI: ${{ secrets.API_URI }} APP_URL: ${{ secrets.APP_URL }} BACKEND_URL: ${{ secrets.BACKEND_URL }} GH_OAUTH_CLIENT_ID: ${{ vars.GH_OAUTH_CLIENT_ID}} diff --git a/README.md b/README.md index bb3f920c2..e22889856 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,6 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati | :------------------------- | :------------- | :---------------------------------- | :------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `APP_URL` | Production | `http://localhost:3000` | | Domain of the hosted app | | `BACKEND_URL` | Production | `http://localhost:3002` | | Url of the API Endpoint | -| `CLIENT_ID` | Production | | | Azure ID: Client ID | -| `TENANT_ID` | Production | | | Azure AD: Tenant Id | -| `API_URI` | Production | | | Azure AD: API Url | -| `STUDENT_USERNAME_PATTERN` | Production | | `@edu` | Users with usernames matching this RegExp pattern are displayed as students (regardless of admin status). If unset, all non-admin users are displayed as students. | | `DEFAULT_TEST_USER` | Development | | `admin.bar@bazz.ch` | To log in offline. Email of the user to be selected by default. Must correspond to a user email found in the API's database.\* | | `OFFLINE_API` | Dev/Production | `memory` | `true` | `memory` | `indexedDB` | In case the project shall be fully functional, but API persistent data is not needed (e.g. when run in Github Codespace), set this option to true (=`memory`). | | `SENTRY_DSN` | Production | | | Sentry DSN for error tracking | diff --git a/docs/tdev/app-architecture/api-deploy/index.mdx b/docs/tdev/app-architecture/api-deploy/index.mdx index 5f88a8ec6..65d8b31a6 100644 --- a/docs/tdev/app-architecture/api-deploy/index.mdx +++ b/docs/tdev/app-architecture/api-deploy/index.mdx @@ -5,7 +5,7 @@ page_id: 560cfd4d-4464-42ff-a121-a3d116ea1994 import Steps from '@tdev-components/Steps' import { Val, TemplateCode, DynamicInput } from '@tdev-components/DynamicValues'; import _ from 'es-toolkit/compat'; -import { generateRandomBase64 } from './secureToken'; +import { generateRandomBase64 } from '../helpers/secureToken'; # API Aufsetzen @@ -16,7 +16,6 @@ import { generateRandomBase64 } from './secureToken'; _.camelCase((page.dynamicValues.get('app') || 'inf-teaching-api'))} /> - generateRandomBase64()} /> diff --git a/docs/tdev/app-architecture/dokku/index.mdx b/docs/tdev/app-architecture/dokku/index.mdx index 35cc9c8ee..60d8b97f5 100644 --- a/docs/tdev/app-architecture/dokku/index.mdx +++ b/docs/tdev/app-architecture/dokku/index.mdx @@ -4,6 +4,7 @@ page_id: 323ff390-40d6-4bd5-ac6c-7a05f3515526 import Steps from '@tdev-components/Steps' import { Val, TemplateCode, DynamicInput } from '@tdev-components/DynamicValues'; +import { generateRandomBase64 } from '../helpers/secureToken'; # Dokku Deploy @@ -18,8 +19,8 @@ Durch das Hosten auf einem eigenen Server kann mit bspw. `http-auth` der Zugriff 1. Eine neue App ____ auf dem Server erstellen - - + + generateRandomBase64()} /> @@ -34,7 +35,7 @@ Durch das Hosten auf einem eigenen Server kann mit bspw. `http-auth` der Zugriff dokku config:set --no-restart {{app}} API_URI={{API_URI}} dokku config:set --no-restart {{app}} APP_URL=https://{{domain}} - dokku config:set --no-restart {{app}} BACKEND_URL={{BACKEND_URL}} + dokku config:set --no-restart {{app}} BETTER_AUTH_URL={{BETTER_AUTH_URL}} dokku config:set --no-restart {{app}} CLIENT_ID={{CLIENT_ID}} dokku config:set --no-restart {{app}} TENANT_ID={{TENANT_ID}} ``` @@ -44,7 +45,7 @@ Durch das Hosten auf einem eigenen Server kann mit bspw. `http-auth` der Zugriff ```bash title="/home/dokku/{{app}}/ENV" API_URI="{{API_URI}}" APP_URL="https://{{domain}}" - BACKEND_URL="{{BACKEND_URL}}" + BETTER_AUTH_URL="{{BETTER_AUTH_URL}}" CLIENT_ID="{{CLIENT_ID}}" TENANT_ID="{{TENANT_ID}}" NGINX_ROOT="build" diff --git a/docs/tdev/app-architecture/api-deploy/secureToken.ts b/docs/tdev/app-architecture/helpers/secureToken.ts similarity index 100% rename from docs/tdev/app-architecture/api-deploy/secureToken.ts rename to docs/tdev/app-architecture/helpers/secureToken.ts diff --git a/docusaurus.config.ts b/docusaurus.config.ts index b0c4cbe1e..324491ad2 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -71,8 +71,6 @@ const config: Config = applyTransformers({ /** Use test user in local dev: set DEFAULT_TEST_USER to the default test users email adress*/ TEST_USER: DEFAULT_TEST_USER, OFFLINE_API: OFFLINE_API, - /** User.ts#isStudent returns `true` for users matching this pattern. If unset, it returns `true` for all non-admin users. */ - STUDENT_USERNAME_PATTERN: process.env.STUDENT_USERNAME_PATTERN, NO_AUTH: (process.env.NODE_ENV !== 'production' || OFFLINE_API) && !!DEFAULT_TEST_USER, /** The Domain Name where the api is running */ APP_URL: process.env.NETLIFY @@ -82,12 +80,6 @@ const config: Config = applyTransformers({ : process.env.APP_URL || 'http://localhost:3000', /** The Domain Name of this app */ BACKEND_URL: process.env.BACKEND_URL || 'http://localhost:3002', - /** The application id generated in https://portal.azure.com */ - CLIENT_ID: process.env.CLIENT_ID, - /** Tenant / Verzeichnis-ID (Mandant) */ - TENANT_ID: process.env.TENANT_ID, - /** The application id uri generated in https://portal.azure.com */ - API_URI: process.env.API_URI, GIT_COMMIT_SHA: GIT_COMMIT_SHA, SENTRY_DSN: process.env.SENTRY_DSN, GH_OAUTH_CLIENT_ID: GH_OAUTH_CLIENT_ID, diff --git a/package.json b/package.json index 26afa9f1f..2a0a06301 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,6 @@ "updateTdev": "ts-node updateSync/updateTdev.ts" }, "dependencies": { - "@azure/msal-browser": "^v3.28.0", - "@azure/msal-react": "^2.2.0", "@docusaurus/core": "^3.8.1", "@docusaurus/faster": "^3.8.1", "@docusaurus/preset-classic": "^3.8.1", @@ -49,6 +47,7 @@ "@sentry/webpack-plugin": "^3.3.1", "ace-builds": "^1.41.0", "axios": "^1.9.0", + "better-auth": "^1.3.26", "browser-image-compression": "^2.0.2", "clsx": "^2.1.1", "docusaurus-plugin-sass": "^0.2.6", diff --git a/src/api/OfflineApi/Adapter/MemoryDb.ts b/src/api/OfflineApi/Adapter/MemoryDb.ts index 5861790ee..18845fb7d 100644 --- a/src/api/OfflineApi/Adapter/MemoryDb.ts +++ b/src/api/OfflineApi/Adapter/MemoryDb.ts @@ -11,9 +11,9 @@ class MemoryDbAdapter implements DbAdapter { async getAll(storeName: string): Promise { if (!this.db[storeName]) { - return []; + return Promise.resolve([]); } - return Object.values(this.db[storeName]) as T[]; + return Promise.resolve(Object.values(this.db[storeName]) as T[]); } async byDocumentRootId( @@ -22,9 +22,9 @@ class MemoryDbAdapter implements DbAdapter { if (!documentRootId) { return Promise.resolve([]); } - return this.filter('documents', (doc) => doc.documentRootId === documentRootId) as Promise< - Document[] - >; + return Promise.resolve( + this.filter('documents', (doc) => doc.documentRootId === documentRootId) as Promise[]> + ); } async put(storeName: string, item: T & { id: string }): Promise { @@ -32,21 +32,24 @@ class MemoryDbAdapter implements DbAdapter { this.db[storeName] = {}; } this.db[storeName][item.id] = item; + return Promise.resolve(); } async delete(storeName: string, id: string): Promise { if (this.db[storeName] && this.db[storeName][id]) { delete this.db[storeName][id]; } + return Promise.resolve(); } async filter(storeName: string, filterFn: (item: T) => boolean): Promise { const allItems = await this.getAll(storeName); - return allItems.filter(filterFn); + return Promise.resolve(allItems.filter(filterFn)); } async destroyDb(): Promise { this.db = {}; console.log('MemoryDbAdapter: Database destroyed'); + return Promise.resolve(); } } diff --git a/src/api/OfflineApi/index.ts b/src/api/OfflineApi/index.ts index 5944f00a3..ebafa175a 100644 --- a/src/api/OfflineApi/index.ts +++ b/src/api/OfflineApi/index.ts @@ -18,6 +18,7 @@ const LOG_REQUESTS = false; let OfflineUser: User = { id: 'c23c0238-4aeb-457f-9a2c-3d2d5d8931c0', email: 'offline.user@tdev.ch', + name: 'Offline User', firstName: 'Offline', lastName: 'User', role: process.env.NODE_ENV === 'production' ? Role.STUDENT : Role.ADMIN, @@ -29,6 +30,7 @@ const DB_NAME = `${siteConfig.organizationName ?? 'gbsl'}-${siteConfig.projectNa const DOCUMENTS_STORE = 'documents'; const STUDENT_GROUPS_STORE = 'studentGroups'; const PERMISSIONS_STORE = 'permissions'; +export const getOfflineUser = () => ({ ...OfflineUser }); const resolveResponse = (data: T, statusCode: number = 200, statusText: string = ''): AxiosPromise => { return Promise.resolve({ @@ -90,6 +92,7 @@ export default class OfflineApi { this.dbAdapter = new MemoryDbAdapter(); } } else { + console.log('setup memory adapter'); this.dbAdapter = new MemoryDbAdapter(); } if (LOG_REQUESTS) { @@ -236,8 +239,6 @@ export default class OfflineApi { return resolveResponse([] as unknown as T); case 'allowedActions': return resolveResponse([] as unknown as T); - case 'checklogin': - return resolveResponse({ user: OfflineUser } as unknown as T, 200, 'ok'); case 'documents': if (id) { const document = await this.dbAdapter.get>(DOCUMENTS_STORE, id); diff --git a/src/api/admin.ts b/src/api/admin.ts index f65a7fd47..6c4a7f4b9 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -22,3 +22,15 @@ export function createAllowedAction( export function allowedActions(signal: AbortSignal): AxiosPromise { return api.get(`/admin/allowedActions`, { signal }); } + +export function linkUserPassword( + userId: string, + userPW: string, + signal: AbortSignal +): AxiosPromise { + return api.post(`/admin/users/${userId}/linkUserPassword`, { pw: userPW }, { signal }); +} + +export function revokeUserPassword(userId: string, signal: AbortSignal): AxiosPromise { + return api.post(`/admin/users/${userId}/revokeUserPassword`, { signal }); +} diff --git a/src/api/base.ts b/src/api/base.ts index 355268682..de2555a09 100644 --- a/src/api/base.ts +++ b/src/api/base.ts @@ -1,11 +1,8 @@ -import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; -import { BACKEND_URL, apiConfig } from '../authConfig'; -import { InteractionRequiredAuthError } from '@azure/msal-browser'; -import { msalInstance } from '../theme/Root'; +import axios, { AxiosInstance } from 'axios'; import siteConfig from '@generated/docusaurus.config'; import OfflineApi from './OfflineApi'; -const { NO_AUTH, OFFLINE_API } = siteConfig.customFields as { - NO_AUTH?: boolean; +const { BACKEND_URL, OFFLINE_API } = siteConfig.customFields as { + BACKEND_URL: string; OFFLINE_API?: boolean | 'memory' | 'indexedDB'; }; @@ -25,97 +22,4 @@ const api: AxiosInstance & { mode?: 'indexedDB' | 'memory'; destroyDb?: () => Pr headers: {} }); -export const setupDefaultAxios = () => { - /** clear all current interceptors and set them up... */ - api.interceptors.request.clear(); - api.interceptors.request.use( - async (config: InternalAxiosRequestConfig) => { - if (config.headers['Authorization']) { - delete config.headers['Authorization']; - } - return config; - }, - (error) => { - Promise.reject(error); - } - ); -}; - -export const setupMsalAxios = () => { - /** clear all current interceptors and set them up... */ - api.interceptors.request.clear(); - api.interceptors.request.use( - async (config: InternalAxiosRequestConfig) => { - if (process.env.NODE_ENV !== 'production' && NO_AUTH) { - return config; - } - // This will only return a non-null value if you have logic somewhere else that calls the setActiveAccount API - // --> @see src/theme/Root.tsx - const activeAccount = msalInstance.getActiveAccount(); - if (activeAccount) { - try { - const accessTokenResponse = await msalInstance.acquireTokenSilent({ - scopes: apiConfig.scopes, - account: activeAccount - }); - const accessToken = accessTokenResponse.accessToken; - if (config.headers && accessToken) { - config.headers['Authorization'] = 'Bearer ' + accessToken; - } - } catch (e) { - delete config.headers['Authorization']; - if (e instanceof InteractionRequiredAuthError) { - // If there are no cached tokens, or the cached tokens are expired, then the user will need to interact - // with the page to get a new token. - console.log('User interaction required to get a new token.', e); - // hacky way to get the user to log in again - only happens on firefox when - // the default "no 3dparty cookies" setting is active - const msalKeys = Object.keys(localStorage).filter((k) => k.startsWith('msal.')); - msalKeys.forEach((k) => localStorage.removeItem(k)); - // proceed with the login - await msalInstance.acquireTokenRedirect({ - scopes: apiConfig.scopes, - account: activeAccount - }); - } - } - } else { - /* - * User is not signed in. Throw error or wait for user to login. - * Do not attempt to log a user in outside of the context of MsalProvider - */ - if (config.headers['Authorization']) { - delete config.headers['Authorization']; - } - } - return config; - }, - (error) => { - Promise.reject(error); - } - ); -}; - -export const setupNoAuthAxios = (userEmail: string) => { - if (process.env.NODE_ENV === 'production') { - return; - } - /** clear all current interceptors and set them up... */ - api.interceptors.request.clear(); - api.interceptors.request.use( - async (config: InternalAxiosRequestConfig) => { - config.headers['Authorization'] = JSON.stringify({ - email: userEmail - }); - return config; - }, - (error) => { - Promise.reject(error); - } - ); -}; -export const checkLogin = (signal: AbortSignal) => { - return api.get('/checklogin', { signal }); -}; - export default api; diff --git a/src/api/user.ts b/src/api/user.ts index e3d17fc5b..95dac96d1 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,10 +1,12 @@ +import { mdiEmailLock, mdiGithub, mdiMicrosoft } from '@mdi/js'; import api from './base'; import { AxiosPromise } from 'axios'; +import { IfmColors } from '@tdev-components/shared/Colors'; export enum Role { - STUDENT = 'STUDENT', - TEACHER = 'TEACHER', - ADMIN = 'ADMIN' + STUDENT = 'student', + TEACHER = 'teacher', + ADMIN = 'admin' } export const RoleNames: { [key in Role]: string } = { @@ -13,18 +15,47 @@ export const RoleNames: { [key in Role]: string } = { [Role.ADMIN]: 'Admin' }; +export const RoleColors: { [key in Role]: string } = { + [Role.STUDENT]: 'blue', + [Role.TEACHER]: 'green', + [Role.ADMIN]: 'red' +}; + export const RoleAccessLevel: { [key in Role]: number } = { [Role.STUDENT]: 0, [Role.TEACHER]: 1, [Role.ADMIN]: 2 }; +export enum AuthProvider { + MICROSOFT = 'microsoft', + CREDENTIAL = 'credential', + GITHUB = 'github' +} + +export const AuthProviderIcons = { + [AuthProvider.MICROSOFT]: mdiMicrosoft, + [AuthProvider.CREDENTIAL]: mdiEmailLock, + [AuthProvider.GITHUB]: mdiGithub +}; + +export const AuthProviderColor = { + [AuthProvider.MICROSOFT]: IfmColors.blue, + [AuthProvider.CREDENTIAL]: IfmColors.info, + [AuthProvider.GITHUB]: IfmColors.black +}; + export type User = { id: string; email: string; + name: string; firstName: string; lastName: string; role: Role; + authProviders?: AuthProvider[]; + banned?: boolean; + banReason?: string; + banExpires?: Date; createdAt: string; updatedAt: string; }; diff --git a/src/auth-client.ts b/src/auth-client.ts new file mode 100644 index 000000000..d6259d513 --- /dev/null +++ b/src/auth-client.ts @@ -0,0 +1,29 @@ +import { inferAdditionalFields } from 'better-auth/client/plugins'; +import { createAuthClient } from 'better-auth/react'; + +import { adminClient } from 'better-auth/client/plugins'; +import { oneTimeTokenClient } from 'better-auth/client/plugins'; +import siteConfig from '@generated/docusaurus.config'; +interface AuthFields { + BACKEND_URL: string; +} +export const { BACKEND_URL } = siteConfig.customFields as any as AuthFields; + +export const authClient = createAuthClient({ + /** The base URL of the server (optional if you're using the same domain) */ + baseURL: BACKEND_URL, + plugins: [ + adminClient(), + oneTimeTokenClient(), + inferAdditionalFields({ + user: { + firstName: { + type: 'string' + }, + lastName: { + type: 'string' + } + } + }) + ] +}); diff --git a/src/authConfig.ts b/src/authConfig.ts deleted file mode 100644 index 03e03b58b..000000000 --- a/src/authConfig.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. - */ - -import { Configuration, LogLevel, type RedirectRequest } from '@azure/msal-browser'; -import siteConfig from '@generated/docusaurus.config'; - -export interface CustomFields { - APP_URL: string; - BACKEND_URL: string; - CLIENT_ID: string; - TENANT_ID: string; - API_URI: string; -} - -/** The Domain Name of this app */ -export const { BACKEND_URL, CLIENT_ID, APP_URL, TENANT_ID, API_URI } = - siteConfig.customFields as any as CustomFields; - -/** - * Configuration object to be passed to MSAL instance on creation. - * For a full list of MSAL.js configuration parameters, visit: - * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md - */ -//cloudDiscoveryMetadata: '{"tenant_discovery_endpoint":"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration","api-version":"1.1", -//"metadata":[{"preferred_network":"login.microsoftonline.com","preferred_cache":"login.windows.net","aliases":["login.microsoftonline.com","login.windows.net","login.microsoft.com","sts.windows.net"]},{"preferred_network":"login.partner.microsoftonline.cn","preferred_cache":"login.partner.microsoftonline.cn","aliases":["login.partner.microsoftonline.cn","login.chinacloudapi.cn"]},{"preferred_network":"login.microsoftonline.de","preferred_cache":"login.microsoftonline.de","aliases":["login.microsoftonline.de"]},{"preferred_network":"login.microsoftonline.us","preferred_cache":"login.microsoftonline.us","aliases":["login.microsoftonline.us","login.usgovcloudapi.net"]},{"preferred_network":"login-us.microsoftonline.com","preferred_cache":"login-us.microsoftonline.com","aliases":["login-us.microsoftonline.com"]}]}' - -export const msalConfig: Configuration = { - auth: { - clientId: CLIENT_ID || 'nope', - authority: `https://login.microsoftonline.com/${TENANT_ID}`, - redirectUri: `${APP_URL}/user`, - postLogoutRedirectUri: APP_URL - }, - cache: { - cacheLocation: 'localStorage', // This configures where your cache will be stored - storeAuthStateInCookie: false // Set this to "true" if you are having issues on IE11 or Edge - }, - system: { - loggerOptions: { - loggerCallback: (level, message, containsPii) => { - if (containsPii) { - return; - } - switch (level) { - case LogLevel.Error: - console.error(message); - return; - case LogLevel.Info: - if (process.env.NODE_ENV !== 'debug') { - return; - } - console.info(message); - return; - case LogLevel.Verbose: - if (process.env.NODE_ENV !== 'debug') { - return; - } - console.debug(message); - return; - case LogLevel.Warning: - console.warn(message); - return; - } - } - } - } -}; - -export const scopes = [`${API_URI}/access_as_user`]; - -// Add here the endpoints and scopes for the web API you would like to use. -export const apiConfig = { - uri: API_URI, - scopes: scopes -}; -/** - * Scopes you add here will be prompted for user consent during sign-in. - * By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request. - * For more information about OIDC scopes, visit: - * https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes - */ -export const loginRequest = { - scopes: scopes -}; - -/** - * Scopes you add here will be used to request a token from Azure AD to be used for accessing a protected resource. - * To learn more about how to work with scopes and resources, see: - * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/resources-and-scopes.md - */ -export const tokenRequest: RedirectRequest = { - scopes: [...apiConfig.scopes] -}; diff --git a/src/components/Admin/ActionRequest/NavReloadRequest.tsx b/src/components/Admin/ActionRequest/NavReloadRequest.tsx index fd5880046..5f0543003 100644 --- a/src/components/Admin/ActionRequest/NavReloadRequest.tsx +++ b/src/components/Admin/ActionRequest/NavReloadRequest.tsx @@ -9,6 +9,7 @@ import { mdiLoading, mdiReloadAlert, mdiWebRefresh } from '@mdi/js'; interface Props { roomIds?: string[]; userIds?: string[]; + slim?: boolean; } const NavReloadRequest = observer((props: Props) => { @@ -30,9 +31,9 @@ const NavReloadRequest = observer((props: Props) => { return ( { spin={isLoading} onConfirm={() => { setIsLoading(true); - socketStore - .requestNavigation(props.roomIds || [], props.userIds || [], { type: 'nav-reload' }) - .finally(() => { - setIsLoading(false); - }); + socketStore.requestReload(props.roomIds || [], props.userIds || []).finally(() => { + setIsLoading(false); + }); }} /> ); diff --git a/src/components/Admin/AdminPanel/index.tsx b/src/components/Admin/AdminPanel/index.tsx index 0c6de1d84..3e2cef3d8 100644 --- a/src/components/Admin/AdminPanel/index.tsx +++ b/src/components/Admin/AdminPanel/index.tsx @@ -8,6 +8,7 @@ import TabItem from '@theme/TabItem'; import StudentGroupPanel from '@tdev-components/Admin/StudentGroupPanel'; import UserTable from '@tdev-components/Admin/UserTable'; import AllowedActions from '../AllowedActions'; +import CreateUser from '../CreateUser'; const AdminPanel = observer(() => { const userStore = useStore('userStore'); @@ -30,6 +31,9 @@ const AdminPanel = observer(() => { + + + diff --git a/src/components/Admin/CreateUser/index.tsx b/src/components/Admin/CreateUser/index.tsx new file mode 100644 index 000000000..506a825e0 --- /dev/null +++ b/src/components/Admin/CreateUser/index.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import Button from '@tdev-components/shared/Button'; +import TextInput from '@tdev-components/shared/TextInput'; +import Card from '@tdev-components/shared/Card'; +import { mdiAccountPlus, mdiCheck } from '@mdi/js'; +import Alert from '@tdev-components/shared/Alert'; + +interface Props {} + +const CreateUser = observer((props: Props) => { + const authStore = useStore('authStore'); + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [firstName, setFirstName] = React.useState(''); + const [lastName, setLastName] = React.useState(''); + const [created, setCreated] = React.useState(false); + const [error, setError] = React.useState(''); + + return ( + Neue Benutzer:in erfassen} + footer={ + <> + + {created && ( + + )} + {error && {error}} + + } + classNames={{ + card: clsx(styles.card) + }} + > + setFirstName(val)} + readOnly={created} + /> + setLastName(val)} + readOnly={created} + /> + setEmail(val)} + readOnly={created} + /> + setPassword(val)} + readOnly={created} + validator={(t) => { + if (t.length < 8) { + return 'Mindestens 8 Zeichen'; + } + return null; + }} + /> + + ); +}); + +export default CreateUser; diff --git a/src/components/Admin/CreateUser/styles.module.scss b/src/components/Admin/CreateUser/styles.module.scss new file mode 100644 index 000000000..843b18133 --- /dev/null +++ b/src/components/Admin/CreateUser/styles.module.scss @@ -0,0 +1,5 @@ +.card { + max-width: 30em; + margin-left: auto; + margin-right: auto; +} diff --git a/src/components/Admin/EditUser/index.tsx b/src/components/Admin/EditUser/index.tsx new file mode 100644 index 000000000..418195018 --- /dev/null +++ b/src/components/Admin/EditUser/index.tsx @@ -0,0 +1,303 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import { Role, RoleAccessLevel, RoleNames, User as UserProps } from '@tdev-api/user'; +import Card from '@tdev-components/shared/Card'; +import TextInput from '@tdev-components/shared/TextInput'; +import Button from '@tdev-components/shared/Button'; +import { authClient } from '@tdev/auth-client'; +import { useStore } from '@tdev-hooks/useStore'; +import { Confirm } from '@tdev-components/shared/Button/Confirm'; +import { mdiAccountCancel, mdiAccountCheck, mdiLink, mdiLinkOff, mdiLoading, mdiTrashCan } from '@mdi/js'; +import { SIZE_XS } from '@tdev-components/shared/iconSizes'; +import { action } from 'mobx'; +import Loader from '@tdev-components/Loader'; +import type User from '@tdev-models/User'; +import Alert from '@tdev-components/shared/Alert'; + +interface Props { + user: User; + close: () => void; +} + +type SpinState = + | 'deleting' + | 'linking' + | 'unlinking' + | 'change-pw' + | 'block-user' + | 'unblock-user' + | 'update-user'; + +const SPIN_TEXT = { + deleting: 'Löschen...', + linking: 'Verknüpfen...', + unlinking: 'Verknüpfung aufheben...', + 'change-pw': 'Passwort ändern...', + 'block-user': 'User blockieren...', + 'unblock-user': 'Blockierung aufheben...', + 'update-user': 'Speichern...' +}; + +const pwValidator = (pw: string) => (pw.length > 7 ? null : 'Passwort muss min. 8 Zeichen haben'); + +const EditUser = observer((props: Props) => { + const { user } = props; + const userStore = useStore('userStore'); + const adminStore = useStore('adminStore'); + const [spinState, setSpinState] = React.useState(null); + const [password, setPassword] = React.useState(''); + const [pwState, setPwState] = React.useState<'error' | 'success' | null>(null); + + const defaultName = React.useRef(`${user.firstName} ${user.lastName}`); + const hasDefaultName = React.useRef(user.name === defaultName.current); + const [name, setName] = React.useState(user.name); + const [firstName, setFirstName] = React.useState(user.firstName); + const [lastName, setLastName] = React.useState(user.lastName); + React.useEffect(() => { + if (!pwState) { + return; + } + const timeout = setTimeout(() => { + setPwState(null); + }, 5000); + return () => clearTimeout(timeout); + }, [pwState]); + return ( + + + ))} + +
+

User Blockieren

+ Verhindert das Einloggen des Users. + {user.banned ? ( + { + setSpinState('unblock-user'); + authClient.admin.unbanUser({ userId: user.id }).finally(() => { + setSpinState(null); + }); + }} + size={SIZE_XS} + /> + ) : ( + { + setSpinState('block-user'); + authClient.admin.banUser({ userId: user.id }).finally(() => { + setSpinState(null); + }); + }} + size={SIZE_XS} + /> + )} +
+
+ Eigenschaften}> + + + + + +

Account

+ + Ein Mail-Passwort Authentifizierungs hinterlegen. Nützlich um sich bspw. auf + Deploy-Previews anzumelden oder um jemandem temporät Zugriff auf den Account zu + geben. + + Das permanente Hinterlegen eines Passworts stellt ein Sicherheitsrisiko dar, + da bspw. keine 2FA nötig ist. + + + + } + > +
+
+ +
+ {user.hasEmailPasswordAuth ? ( +
+ {user.hasEmailPasswordAuth && ( + { + setSpinState('unlinking'); + adminStore.revokeUserPassword(user.id).finally(() => { + setSpinState(null); + }); + }} + disabled={!!spinState} + size={SIZE_XS} + confirmText="Wirklich entfernen?" + /> + )} + {pwState === 'error' && Passwort konnte nicht gesetzt werden.} + {pwState === 'success' && Passwort erfolgreich gesetzt.} +
+ { + setSpinState('deleting'); + authClient.admin.removeUser({ userId: user.id }).then( + action((res) => { + if (res.data?.success) { + userStore.removeFromStore(user.id); + props.close(); + } + }) + ); + }} + color="red" + confirmText="Wirklich löschen?" + disabled={!userStore.current?.isAdmin || user.id === userStore.current?.id} + /> + {!!spinState && } + + ); +}); + +export default EditUser; diff --git a/src/components/Admin/EditUser/styles.module.scss b/src/components/Admin/EditUser/styles.module.scss new file mode 100644 index 000000000..e7a04a497 --- /dev/null +++ b/src/components/Admin/EditUser/styles.module.scss @@ -0,0 +1,18 @@ +.editUser { + position: relative; + .body { + max-height: 80vh; + overflow-y: auto; + .delete { + justify-content: flex-end; + } + :global(.card) { + margin-bottom: 0.5em; + } + .password { + display: flex; + gap: 0.5em; + align-items: flex-end; + } + } +} diff --git a/src/components/Admin/UserTable/User.tsx b/src/components/Admin/UserTable/User.tsx index bcd6742fc..ef55e5526 100644 --- a/src/components/Admin/UserTable/User.tsx +++ b/src/components/Admin/UserTable/User.tsx @@ -6,9 +6,19 @@ import { observer } from 'mobx-react-lite'; import { default as UserModel } from '@tdev-models/User'; import CopyBadge from '@tdev-components/shared/CopyBadge'; import { formatDateTime } from '@tdev-models/helpers/date'; -import { Role, RoleAccessLevel, RoleNames } from '@tdev-api/user'; +import { AuthProviderColor, AuthProviderIcons, RoleColors, RoleNames } from '@tdev-api/user'; import { useStore } from '@tdev-hooks/useStore'; import LiveStatusIndicator from '@tdev-components/LiveStatusIndicator'; +import Icon from '@mdi/react'; +import { mdiAccountCancel, mdiAccountEdit, mdiCloudQuestion } from '@mdi/js'; +import { SIZE_S, SIZE_XS } from '@tdev-components/shared/iconSizes'; +import Button from '@tdev-components/shared/Button'; +import Popup from 'reactjs-popup'; +import EditUser from '../EditUser'; +import { PopupActions } from 'reactjs-popup/dist/types'; +import Badge from '@tdev-components/shared/Badge'; +import NavReloadRequest from '../ActionRequest/NavReloadRequest'; +import { IfmColors } from '@tdev-components/shared/Colors'; interface Props { user: UserModel; @@ -18,6 +28,7 @@ const UserTableRow = observer((props: Props) => { const { user } = props; const userStore = useStore('userStore'); const { current } = userStore; + const ref = React.useRef(null); if (!current) { return null; } @@ -29,38 +40,47 @@ const UserTableRow = observer((props: Props) => { {user.connectedClients > 0 && ( {user.connectedClients} )} + {user.banned && } {user.email} -
- {Object.values(Role).map((role, idx) => ( - - ))} + {RoleNames[user.role]} + + +
+ +
{user.firstName} {user.lastName} + + {user.authProviders.map((u, idx) => ( + + ))} + {formatDateTime(user.createdAt)} {formatDateTime(user.updatedAt)} - + {user.studentGroups.map((group, idx) => ( {group.name} @@ -68,7 +88,11 @@ const UserTableRow = observer((props: Props) => { ))} - + ); diff --git a/src/components/Admin/UserTable/index.tsx b/src/components/Admin/UserTable/index.tsx index dcd5d8dc4..e3bf0288f 100644 --- a/src/components/Admin/UserTable/index.tsx +++ b/src/components/Admin/UserTable/index.tsx @@ -16,6 +16,7 @@ type SortColumn = | 'accessLevel' | 'firstName' | 'lastName' + | 'linkedAccounts' | 'createdAt' | 'updatedAt' | 'groups' @@ -120,6 +121,7 @@ const UserTable = observer((props: Props) => { onClick={() => setSortColumn('accessLevel')} /> +
@@ -56,8 +72,9 @@ const LoginPage = observer(() => { }); const Login = observer(() => { - const sessionStore = useStore('sessionStore'); - if (sessionStore.isLoggedIn || NO_AUTH) { + const { data: session } = authClient.useSession(); + + if (session?.user || NO_AUTH) { return ; } return ; diff --git a/src/pages/signIn/index.tsx b/src/pages/signIn/index.tsx new file mode 100644 index 000000000..0704e2714 --- /dev/null +++ b/src/pages/signIn/index.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import clsx from 'clsx'; +import Layout from '@theme/Layout'; + +import styles from './styles.module.scss'; +import { useStore } from '../../hooks/useStore'; +import Button from '../../components/shared/Button'; +import { authClient } from '@site/src/auth-client'; +import { Redirect } from '@docusaurus/router'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import TextInput from '@tdev-components/shared/TextInput'; + +export default function SignIn(): React.ReactNode { + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const authStore = useStore('authStore'); + + const { data: session } = authClient.useSession(); + const userPage = useBaseUrl('/user'); + + if (session?.user) { + return ; + } + + return ( + +
+

Einloggen

+
+ setEmail(val)} /> + setPassword(val)} + onEnter={() => { + if (email && password) { + authStore.signInWithEmail(email, password); + } + }} + /> + +
+
+
+ ); +} diff --git a/src/pages/signIn/styles.module.scss b/src/pages/signIn/styles.module.scss new file mode 100644 index 000000000..2893b105e --- /dev/null +++ b/src/pages/signIn/styles.module.scss @@ -0,0 +1,8 @@ +.form { + display: flex; + gap: 10px; + flex-wrap: wrap; + > input { + flex-basis: 20em; + } +} diff --git a/src/pages/user/index.tsx b/src/pages/user/index.tsx index 967eb9683..ca8d674f3 100644 --- a/src/pages/user/index.tsx +++ b/src/pages/user/index.tsx @@ -8,14 +8,12 @@ import { mdiArrowRightThin, mdiBackupRestore, mdiCircle, + mdiCloudQuestion, mdiDeleteEmptyOutline, mdiHarddiskRemove, mdiLogout, mdiRefresh } from '@mdi/js'; -import { useMsal } from '@azure/msal-react'; -import { useIsAuthenticated } from '@azure/msal-react'; -import { InteractionStatus } from '@azure/msal-browser'; import siteConfig from '@generated/docusaurus.config'; import { useStore } from '@tdev-hooks/useStore'; import Button from '@tdev-components/shared/Button'; @@ -25,14 +23,15 @@ import Icon from '@mdi/react'; import UserTable from '@tdev-components/Admin/UserTable'; import NavReloadRequest from '@tdev-components/Admin/ActionRequest/NavReloadRequest'; import Storage from '@tdev-stores/utils/Storage'; -import { logout } from '@tdev-api/user'; +import { AuthProviderColor, AuthProviderIcons, logout } from '@tdev-api/user'; import SelectInput from '@tdev-components/shared/SelectInput'; import useIsBrowser from '@docusaurus/useIsBrowser'; import { useIsLive } from '@tdev-hooks/useIsLive'; import Badge from '@tdev-components/shared/Badge'; -import { SIZE_M } from '@tdev-components/shared/iconSizes'; +import { SIZE_M, SIZE_XS } from '@tdev-components/shared/iconSizes'; import { Confirm } from '@tdev-components/shared/Button/Confirm'; import api from '@tdev-api/base'; +import { authClient } from '@tdev/auth-client'; const { NO_AUTH, OFFLINE_API, TEST_USER } = siteConfig.customFields as { NO_AUTH?: boolean; @@ -59,36 +58,25 @@ const LeftAlign = (text: String) => { const UserPage = observer(() => { const isBrowser = useIsBrowser(); const sessionStore = useStore('sessionStore'); + const authStore = useStore('authStore'); + const adminStore = useStore('adminStore'); const userStore = useStore('userStore'); const socketStore = useStore('socketStore'); const groupStore = useStore('studentGroupStore'); - const isAuthenticated = useIsAuthenticated(); - const { inProgress } = useMsal(); + const { data: session } = authClient.useSession(); const isLive = useIsLive(); const { viewedUser, current } = userStore; if (OFFLINE_API && !isBrowser) { return ; } - if ( - !NO_AUTH && - ((sessionStore.currentUserId && !sessionStore.isLoggedIn) || inProgress !== InteractionStatus.None) - ) { + if (!NO_AUTH && sessionStore.currentUserId && !session?.user) { return ; } - if (!NO_AUTH && !(sessionStore.isLoggedIn || isAuthenticated)) { + if (!NO_AUTH && !session?.user) { return ; } const connectedClients = socketStore.connectedClients.get(viewedUser?.id || ' '); - const setTestUser = (username: string) => { - sessionStore.setAccount({ username: username } as any); - Storage.set('SessionStore', { - user: { email: username } - }); - logout(new AbortController().signal); - window.location.reload(); - }; - return (
@@ -118,6 +106,18 @@ const UserPage = observer(() => {
Email
{viewedUser?.email}
+
Anmelden über
+
+ {viewedUser?.authProviders?.map((auth, idx) => ( + + ))} +
Ist mein Gerät mit dem Server Verbunden?
{ iconSide="left" />
- {NO_AUTH && ( - <> -
Test-User wechseln
-
-
- user.email)} - onChange={(username) => setTestUser(username)} - value={(sessionStore.account as any)?.username} - disabled={userStore.users.length <= 1} - placeholder={ - TEST_USER || 'DEFAULT_TEST_USER nicht definiert in .env' - } - /> - {(sessionStore.account as any)?.username !== - TEST_USER?.toLowerCase() && ( -
-
- - )}
Ausloggen
-
LocalStorage Löschen
-
-
)} {sessionStore.apiMode !== 'api' && ( diff --git a/src/stores/AdminStore.ts b/src/stores/AdminStore.ts index 877b3d68b..b882250b0 100644 --- a/src/stores/AdminStore.ts +++ b/src/stores/AdminStore.ts @@ -6,11 +6,13 @@ import { AllowedAction, allowedActions as apiAllowedActions, deleteAllowedAction as apiDeleteAllowedAction, - createAllowedAction as apiCreateAllowedAction + createAllowedAction as apiCreateAllowedAction, + linkUserPassword, + revokeUserPassword } from '@tdev-api/admin'; import { DocumentType } from '@tdev-api/document'; -export class AdminStore extends iStore { +export class AdminStore extends iStore<`set-user-pw-${string}` | `revoke-user-pw-${string}`> { readonly root: RootStore; allowedActions = observable([]); constructor(root: RootStore) { @@ -54,4 +56,24 @@ export class AdminStore extends iStore { ); }); } + + @action + setUserPassword(userId: string, newPassword: string) { + if (!this.root.userStore.current?.isAdmin) { + return Promise.reject(new Error('Not authorized')); + } + return this.withAbortController(`set-user-pw-${userId}`, async (ct) => { + return linkUserPassword(userId, newPassword, ct.signal); + }); + } + + @action + revokeUserPassword(userId: string) { + if (!this.root.userStore.current?.isAdmin) { + return Promise.reject(new Error('Not authorized')); + } + return this.withAbortController(`revoke-user-pw-${userId}`, async (ct) => { + return revokeUserPassword(userId, ct.signal); + }); + } } diff --git a/src/stores/AuthStore.ts b/src/stores/AuthStore.ts new file mode 100644 index 000000000..7a6288c51 --- /dev/null +++ b/src/stores/AuthStore.ts @@ -0,0 +1,53 @@ +import { action } from 'mobx'; +import _ from 'es-toolkit/compat'; +import type { RootStore } from './rootStore'; +import { authClient } from '../auth-client'; + +export class AuthStore { + readonly root: RootStore; + constructor(root: RootStore) { + this.root = root; + } + + @action + createUser(email: string, password: string, firstName: string, lastName: string) { + return authClient.admin.createUser({ + email, + password, + name: `${firstName} ${lastName}`, + data: { + firstName, + lastName + } + }); + } + + @action + async signInWithEmail(email: string, password: string) { + const { data, error } = await authClient.signIn.email( + { + email, + password + }, + { + onRequest: (ctx) => { + console.log('sign up request started', ctx); + }, + onSuccess: (ctx) => { + console.log('sign up successful', ctx); + //redirect to the dashboard or sign in page + }, + onError: (ctx) => { + // display the error message + console.log('sign up failed', ctx.error.message); + } + } + ); + } + + @action + signOut() { + this.root.socketStore.disconnect(); + return authClient.signOut(); + } +} diff --git a/src/stores/SessionStore.ts b/src/stores/SessionStore.ts index 70de2cbf5..6c9e846b2 100644 --- a/src/stores/SessionStore.ts +++ b/src/stores/SessionStore.ts @@ -1,31 +1,16 @@ -import { AccountInfo, IPublicClientApplication } from '@azure/msal-browser'; -import { action, computed, observable } from 'mobx'; +import { action, observable } from 'mobx'; import { RootStore } from '@tdev-stores/rootStore'; -import { logout } from '@tdev-api/user'; -import Storage, { PersistedData, StorageKey } from '@tdev-stores/utils/Storage'; import iStore from '@tdev-stores/iStore'; -import api, { checkLogin as apiCheckLogin } from '@tdev-api/base'; +import api from '@tdev-api/base'; import { mdiContentSaveOffOutline, mdiDatabaseSyncOutline, mdiHarddisk } from '@mdi/js'; -class State { - @observable.ref accessor account: AccountInfo | undefined | null = undefined; - - @observable.ref accessor _msalInstance: IPublicClientApplication | undefined = undefined; - - constructor() {} -} - export class SessionStore extends iStore<'checkLogin'> { readonly root: RootStore; - @observable.ref private accessor stateRef: State = new State(); - - @observable accessor authMethod: 'apiKey' | 'msal'; - - @observable accessor currentUserId: string | undefined; - @observable accessor initialized = false; + @observable accessor isLoggedIn = false; + @observable accessor currentUserId: string | undefined; @observable accessor storageSyncInitialized = false; fileSystemDirectoryHandles = observable.map([], { deep: false }); @@ -33,16 +18,19 @@ export class SessionStore extends iStore<'checkLogin'> { constructor(store: RootStore) { super(); this.root = store; - const data = Storage.get('SessionStore', {} as PersistedData); - if (data?.user) { - this.authMethod = 'apiKey'; - this.rehydrate(data); - } else { - this.authMethod = 'msal'; - } this.initialized = true; } + @action + setCurrentUserId(userId: string | undefined) { + this.currentUserId = userId; + } + + @action + setIsLoggedIn(loggedIn: boolean) { + this.isLoggedIn = loggedIn; + } + get apiMode(): 'indexedDB' | 'memory' | 'api' { return api.mode ?? 'api'; } @@ -57,15 +45,6 @@ export class SessionStore extends iStore<'checkLogin'> { return mdiContentSaveOffOutline; // Use a different icon if needed } - @action - rehydrate(data: PersistedData) { - this.authMethod = !!data?.user ? 'apiKey' : 'msal'; - if (!data.user || this.currentUserId) { - return; - } - this.currentUserId = data.user?.id; - } - @action setFileSystemDirectoryHandle(key: string, handle?: FileSystemDirectoryHandle) { if (!handle) { @@ -73,73 +52,4 @@ export class SessionStore extends iStore<'checkLogin'> { } this.fileSystemDirectoryHandles.set(key, handle); } - - @action - logout() { - const sig = new AbortController(); - logout(sig.signal) - .catch((err) => { - console.error('Failed to logout', err); - }) - .finally(() => { - Storage.remove('SessionStore'); - localStorage.clear(); - window.location.reload(); - }); - } - - @action - setMsalStrategy() { - Storage.remove('SessionStore'); - this.authMethod = 'msal'; - } - - @computed - get account(): AccountInfo | null | undefined { - return this.stateRef.account; - } - - @computed - get userId() { - return this.currentUserId || this.account?.localAccountId; - } - - @action - setAccount(account?: AccountInfo | null) { - this.stateRef.account = account; - } - - @computed - get isLoggedIn(): boolean { - return this.authMethod === 'apiKey' ? !!this.currentUserId : !!this.stateRef.account; - } - - @action - setupStorageSync() { - if (this.storageSyncInitialized) { - return; - } - window.addEventListener('storage', (event) => { - if (event.key === StorageKey['SessionStore'] && event.newValue) { - const newData: PersistedData | null = JSON.parse(event.newValue); - - // data may be null if key is deleted in localStorage - if (!newData) { - return; - } - - // If we're not signed in then hydrate from the received data, otherwise if - // we are signed in and the received data contains no user then sign out - if (this.isLoggedIn) { - if (newData.user === null) { - void this.logout(); - } - } else { - this.rehydrate(newData); - this.root.userStore.rehydrate(newData); - } - } - }); - this.storageSyncInitialized = true; - } } diff --git a/src/stores/SocketDataStore.ts b/src/stores/SocketDataStore.ts index b258709a2..dc65598bd 100644 --- a/src/stores/SocketDataStore.ts +++ b/src/stores/SocketDataStore.ts @@ -1,7 +1,7 @@ import { RootStore } from '@tdev-stores/rootStore'; import { io, Socket } from 'socket.io-client'; import { action, observable, reaction } from 'mobx'; -import { checkLogin as pingApi, default as api } from '@tdev-api/base'; +import { default as api } from '@tdev-api/base'; import iStore from '@tdev-stores/iStore'; import { Action, @@ -16,7 +16,6 @@ import { RecordType, ServerToClientEvents } from '../api/IoEventTypes'; -import { BACKEND_URL } from '../authConfig'; import { DocumentRoot, DocumentRootUpdate } from '@tdev-api/documentRoot'; import { GroupPermission, UserPermission } from '@tdev-api/permission'; import { Document, DocumentType } from '../api/document'; @@ -25,7 +24,12 @@ import { CmsSettings } from '@tdev-api/cms'; import { StudentGroup as ApiStudentGroup } from '@tdev-api/studentGroup'; import StudentGroup from '@tdev-models/StudentGroup'; import siteConfig from '@generated/docusaurus.config'; -const { OFFLINE_API } = siteConfig.customFields as { OFFLINE_API?: boolean | 'memory' | 'indexedDB' }; +import { authClient } from '@tdev/auth-client'; +import { User } from '@tdev-api/user'; +const { OFFLINE_API, BACKEND_URL } = siteConfig.customFields as { + OFFLINE_API?: boolean | 'memory' | 'indexedDB'; + BACKEND_URL: string; +}; type TypedSocket = Socket; /** @@ -46,8 +50,6 @@ export class SocketDataStore extends iStore<'ping'> { @observable accessor isLive: boolean = false; - @observable accessor isConfigured = false; - @observable.ref accessor actionRequest: Action['action'] | undefined = undefined; connectedClients = observable.map(); @@ -56,19 +58,12 @@ export class SocketDataStore extends iStore<'ping'> { super(); this.root = root; - api.interceptors.response.use( - (res) => res, - (error) => { - if (error.response?.status === 401) { - this.disconnect(); - } - return Promise.reject(error); - } - ); reaction( () => this.isLive, action((isLive) => { - console.log('Socket.IO live:', isLive); + if (!OFFLINE_API) { + console.log('Socket.IO live:', isLive); + } }) ); } @@ -100,7 +95,7 @@ export class SocketDataStore extends iStore<'ping'> { this.isLive = isLive; } - connect() { + async connect() { if (OFFLINE_API) { if (!this.isLive) { this.setLiveState(true); @@ -110,19 +105,36 @@ export class SocketDataStore extends iStore<'ping'> { if (this.socket?.connected) { return; } + + const { data, error } = await authClient.oneTimeToken.generate().catch((e) => { + return { data: { token: undefined }, error: e }; + }); + if (error || !data?.token) { + console.log('cannot get one-time-token', error); + setTimeout(() => this.connect(), 1000); + return; + } const ws_url = BACKEND_URL; const socket = io(ws_url, { - withCredentials: true, + autoConnect: false, + auth: { + token: data.token + }, transports: ['websocket', 'webtransport'], reconnection: false }); + this._connect(socket); } @action _connect(socket: TypedSocket) { this._socketConfig(socket); + const winSock: { tdevSockets?: Socket[] } = window as any; + winSock.tdevSockets = (winSock.tdevSockets || []).filter((s: Socket) => s.connected || s.active); + winSock.tdevSockets.forEach((s: Socket) => s.disconnect()); socket.connect(); + winSock.tdevSockets.push(socket); } _socketConfig(socket: TypedSocket) { @@ -136,10 +148,10 @@ export class SocketDataStore extends iStore<'ping'> { this._disconnect(this.socket); } /** - * maybe there is a newer version to add headers? - * @see https://socket.io/docs/v4/client-options/#extraheaders + * add sid to the api headers, so that the api can broadcast messages to + * the user except the initiating client. */ - api.defaults.headers.common['x-metadata-socketid'] = socket?.id; + api.defaults.headers.common['x-metadata-sid'] = socket?.id; this.socket = socket; this.setLiveState(true); }) @@ -148,18 +160,11 @@ export class SocketDataStore extends iStore<'ping'> { socket.on( 'disconnect', action((reason) => { - console.log('disconnect', socket?.id); this.socket = undefined; this.setLiveState(false); if (reason !== 'io server disconnect' && reason !== 'io client disconnect') { // an error happened, try to reconnect - this.checkLogin().then( - action((reconnect) => { - if (reconnect) { - this.reconnect(); - } - }) - ); + this.reconnect(); } }) ); @@ -167,13 +172,7 @@ export class SocketDataStore extends iStore<'ping'> { 'connect_error', action((err) => { console.log('connection error', err); - this.checkLogin().then( - action((reconnect) => { - if (reconnect) { - this.reconnect(); - } - }) - ); + // TODO: should we try to connect again in 1s? }) ); socket.on(IoEvent.NEW_RECORD, this.createRecord.bind(this)); @@ -268,6 +267,9 @@ export class SocketDataStore extends iStore<'ping'> { const studentGroup = record as ApiStudentGroup; this.root.studentGroupStore.handleUpdate(studentGroup); break; + case RecordType.User: + this.root.userStore.addToStore(record as User); + break; default: console.log('changedRecord', type, record); break; @@ -320,47 +322,12 @@ export class SocketDataStore extends iStore<'ping'> { } } - checkLogin() { - if (this.root.sessionStore.isLoggedIn) { - return this.withAbortController('ping', (sig) => { - return pingApi(sig.signal) - .then(({ status }) => { - if (status === 200 && !this.isLive) { - return true; - } else { - return false; - } - }) - .catch((err) => { - return false; - }); - }); - } - return Promise.resolve(false); - } - @action resetUserData() { this.disconnect(); api.defaults.headers.common['x-metadata-socketid'] = undefined; } - @action - configure() { - return this.checkLogin() - .then((reconnect) => { - if (reconnect) { - this.reconnect(); - } - return []; - }) - .finally( - action(() => { - this.isConfigured = true; - }) - ); - } - @action joinRoom(roomId: string) { this.socket?.emit(IoClientEvent.JOIN_ROOM, roomId, (joined) => { @@ -387,6 +354,11 @@ export class SocketDataStore extends iStore<'ping'> { }); } + @action + requestReload(roomIds: string[], userIds: string[]) { + return this.requestNavigation(roomIds || [], userIds || [], { type: 'nav-reload' }); + } + @action cleanup() { this.disconnect(); diff --git a/src/stores/UserStore.ts b/src/stores/UserStore.ts index 1d1790e44..30d39274e 100644 --- a/src/stores/UserStore.ts +++ b/src/stores/UserStore.ts @@ -109,11 +109,6 @@ export class UserStore extends iStore<`update-${string}`> { @computed get current(): User | undefined { - if (this.root.sessionStore?.authMethod === 'msal') { - return this.users.find( - (u) => u.email?.toLowerCase() === this.root?.sessionStore?.account?.username?.toLowerCase() - ); - } return this.users.find((u) => u.id === this.root.sessionStore?.currentUserId); } @@ -126,7 +121,7 @@ export class UserStore extends iStore<`update-${string}`> { if (!this.current?.hasElevatedAccess) { return this.current?.id; } - return this._viewedUserId || this.current?.id || this.root.sessionStore.userId; + return this._viewedUserId || this.current?.id || this.root.sessionStore.currentUserId; } @computed @@ -181,20 +176,11 @@ export class UserStore extends iStore<`update-${string}`> { const res = this.withAbortController('load-user', async (signal) => { return currentUser(signal.signal).then((res) => { const currentUser = this.addToStore(res.data); - if (currentUser) { - Storage.set('SessionStore', { - user: { ...currentUser.props, role: Role.STUDENT } - }); - } return currentUser; }); - }).catch( - action((e) => { - if (this.root.sessionStore.authMethod === 'apiKey') { - this.root.sessionStore.setMsalStrategy(); - } - }) - ); + }).catch(() => { + console.log('no current user'); + }); return res; } diff --git a/src/stores/rootStore.ts b/src/stores/rootStore.ts index 8fda98bef..5e170a28e 100644 --- a/src/stores/rootStore.ts +++ b/src/stores/rootStore.ts @@ -11,6 +11,7 @@ import { PageStore } from '@tdev-stores/PageStore'; import { AdminStore } from '@tdev-stores/AdminStore'; import { CmsStore } from '@tdev-stores/CmsStore'; import SiteStore from '@tdev-stores/SiteStore'; +import { AuthStore } from './AuthStore'; export class RootStore { documentRootStore: DocumentRootStore; @@ -24,6 +25,7 @@ export class RootStore { adminStore: AdminStore; cmsStore: CmsStore; siteStore: SiteStore; + authStore: AuthStore; // @observable accessor initialized = false; constructor() { @@ -38,14 +40,13 @@ export class RootStore { this.adminStore = new AdminStore(this); this.cmsStore = new CmsStore(this); this.siteStore = new SiteStore(this); - - if (this.sessionStore.isLoggedIn) { - this.load(); - } + this.authStore = new AuthStore(this); } @action - load() { + load(userId: string) { + this.sessionStore.setCurrentUserId(userId); + this.sessionStore.setIsLoggedIn(!!userId); this.userStore.loadCurrent().then((user) => { if (user) { this.socketStore.reconnect(); @@ -68,6 +69,7 @@ export class RootStore { * could be probably ignored since the page gets reloaded on logout? */ console.log('cleanup data stores'); + this.sessionStore.setIsLoggedIn(false); this.userStore.cleanup(); this.socketStore.cleanup(); this.studentGroupStore.cleanup(); diff --git a/src/theme/Root.tsx b/src/theme/Root.tsx index 6fd0a3714..3200f5321 100644 --- a/src/theme/Root.tsx +++ b/src/theme/Root.tsx @@ -1,161 +1,19 @@ import React from 'react'; -import { MsalProvider, useIsAuthenticated, useMsal } from '@azure/msal-react'; import { StoresProvider, rootStore } from '@tdev-stores/rootStore'; import { observer } from 'mobx-react-lite'; -import { TENANT_ID, msalConfig } from '@tdev/authConfig'; import Head from '@docusaurus/Head'; import siteConfig from '@generated/docusaurus.config'; -import { AccountInfo, EventType, InteractionStatus, PublicClientApplication } from '@azure/msal-browser'; -import { setupMsalAxios, setupNoAuthAxios } from '@tdev-api/base'; import { useStore } from '@tdev-hooks/useStore'; -import { reaction, runInAction } from 'mobx'; +import { reaction } from 'mobx'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import scheduleMicrotask from '@tdev-components/util/scheduleMicrotask'; import { useHistory } from '@docusaurus/router'; -import Storage from '@tdev-stores/utils/Storage'; -import { noAuthMessage, offlineApiMessage } from './Root.helpers'; import LoggedOutOverlay from '@tdev-components/LoggedOutOverlay'; -const { NO_AUTH, OFFLINE_API, TEST_USER, SENTRY_DSN } = siteConfig.customFields as { - TEST_USER?: string; - NO_AUTH?: boolean; +import { authClient } from '@tdev/auth-client'; +import { getOfflineUser } from '@tdev-api/OfflineApi'; +const { OFFLINE_API, SENTRY_DSN } = siteConfig.customFields as { SENTRY_DSN?: string; OFFLINE_API?: boolean | 'memory' | 'indexedDB'; }; -export const msalInstance = new PublicClientApplication(msalConfig); - -const currentTestUsername = Storage.get('SessionStore', { - user: { email: TEST_USER } -})?.user?.email?.toLowerCase(); - -if (NO_AUTH) { - if (OFFLINE_API) { - console.log(offlineApiMessage(OFFLINE_API ?? 'memory')); - } else { - console.log(noAuthMessage(currentTestUsername)); - } -} - -const MsalWrapper = observer(({ children }: { children: React.ReactNode }) => { - const sessionStore = useStore('sessionStore'); - React.useEffect(() => { - if (NO_AUTH && process.env.NODE_ENV !== 'production' && currentTestUsername) { - setupNoAuthAxios(currentTestUsername); - } - }, []); - React.useEffect(() => { - if (OFFLINE_API) { - return rootStore.load(); - } - /** - * DEV MODE - * - no auth - */ - if (NO_AUTH && !sessionStore?.isLoggedIn && currentTestUsername) { - runInAction(() => { - sessionStore.authMethod = 'msal'; - }); - - scheduleMicrotask(() => { - rootStore.sessionStore.setAccount({ - username: currentTestUsername - } as any); - }); - - rootStore.load(); - return; - } - - if (!sessionStore?.initialized) { - return; - } - /** - * PROD MODE - * - auth over cookie - */ - if (sessionStore.authMethod === 'apiKey') { - return; - } - - /** - * PROD MODE - * - auth over msal - */ - msalInstance.initialize().then(() => { - if (!msalInstance.getActiveAccount() && msalInstance.getAllAccounts().length > 0) { - // Account selection logic is app dependent. Adjust as needed for different use cases. - const account = msalInstance - .getAllAccounts() - .filter((a) => a.tenantId === TENANT_ID) - .find((a) => /@(edu\.)?(gbsl|gbjb)\.ch/.test(a.username)); - if (account) { - msalInstance.setActiveAccount(account); - } - } - msalInstance.enableAccountStorageEvents(); - msalInstance.addEventCallback((event) => { - if ( - event.eventType === EventType.LOGIN_SUCCESS && - (event.payload as { account: AccountInfo }).account - ) { - const account = (event.payload as { account: AccountInfo }).account; - msalInstance.setActiveAccount(account); - } - }); - }); - }, [msalInstance, sessionStore?.authMethod]); - - React.useEffect(() => { - if (NO_AUTH) { - /** - * TODO: load the authorized entities, if needed - */ - } - }, [NO_AUTH, rootStore]); - - if (NO_AUTH || OFFLINE_API) { - return children; - } - return ( - - - {children} - - ); -}); - -const MsalAccount = observer(() => { - const { accounts, inProgress, instance } = useMsal(); - const isAuthenticated = useIsAuthenticated(); - const sessionStore = useStore('sessionStore'); - - React.useEffect(() => { - if (sessionStore.authMethod === 'apiKey' && !NO_AUTH) { - return; - } - if (isAuthenticated && inProgress === InteractionStatus.None) { - const active = instance.getActiveAccount(); - if (active) { - /** - * order matters - * 1. setup axios with the correct tokens - * 2. set the msal instance and account to the session store - * 3. load authorized entities - */ - setupMsalAxios(); - scheduleMicrotask(() => { - rootStore.sessionStore.setAccount(active); - rootStore.load(); - }); - } - } - }, [sessionStore?.authMethod, accounts, inProgress, instance, isAuthenticated]); - return ( -
- ); -}); const RemoteNavigationHandler = observer(() => { const socketStore = useStore('socketStore'); @@ -186,46 +44,45 @@ const RemoteNavigationHandler = observer(() => { return null; }); -// Default implementation, that you can customize -function Root({ children }: { children: React.ReactNode }) { - React.useEffect(() => { - if (!rootStore) { - return; - } - rootStore.sessionStore.setupStorageSync(); - if (window) { - if ((window as any).store && (window as any).store !== rootStore) { - try { - (window as any).store.cleanup(); - } catch (e) { - console.error('Failed to cleanup the store', e); - } - } - (window as any).store = rootStore; - } - return () => { - /** - * TODO: cleanup the store - * - remove all listeners - * - clear all data - * - disconnect all sockets - */ - // rootStore?.cleanup(); - }; - }, [rootStore]); - - const { siteConfig } = useDocusaurusContext(); +const ExposeRootStoreToWindow = observer(() => { React.useEffect(() => { /** * Expose the store to the window object */ (window as any).store = rootStore; }, [rootStore]); + return null; +}); + +const Authentication = observer(() => { + const { data: session } = authClient.useSession(); + React.useEffect(() => { + if (!rootStore) { + return; + } + if (session?.user) { + rootStore.load(session.user.id); + } else { + rootStore.cleanup(); + } + }, [session?.user, rootStore]); + return null; +}); + +const OfflineApi = observer(() => { React.useEffect(() => { - // load sentry - if (!SENTRY_DSN) { + if (!OFFLINE_API) { return; } + console.log('Using Offline API mode:', OFFLINE_API); + const offlineUser = getOfflineUser(); + rootStore.load(offlineUser.id); + }, []); + return null; +}); + +const Sentry = observer(() => { + React.useEffect(() => { import('@sentry/react') .then((Sentry) => { if (Sentry) { @@ -241,33 +98,11 @@ function Root({ children }: { children: React.ReactNode }) { console.error('Sentry failed to load'); }); }, [SENTRY_DSN]); + return null; +}); - React.useEffect(() => { - let timeoutId: ReturnType; - const handleVisibilityChange = () => { - if (document.hidden) { - /** - * eventuall we could disconnect the socket - * or at least indicate to admins that the user has left the page (e.g. for exams) - */ - // rootStore.socketStore.disconnect(); - } else { - /** - * make sure to reconnect the socket when the user returns to the page - * The delay is added to avoid reconnecting too quickly - */ - timeoutId = setTimeout(() => { - rootStore.socketStore.reconnect(); - }, 3000); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - clearTimeout(timeoutId); - }; - }, [rootStore]); +function Root({ children }: { children: React.ReactNode }) { + const { siteConfig } = useDocusaurusContext(); return ( <> @@ -279,12 +114,44 @@ function Root({ children }: { children: React.ReactNode }) { /> - {children} - - {!OFFLINE_API && } + + {OFFLINE_API ? ( + + ) : ( + <> + + + + + )} + {SENTRY_DSN && } + {children} ); } export default Root; + +// React.useEffect(() => { +// // let timeoutId: ReturnType; +// const handleVisibilityChange = () => { +// if (document.hidden) { +// /** +// * The Browser-Window is now hidden +// * we could indicate to admins that the user has left the page +// * (e.g. for exams) +// */ +// } else { +// /** +// * The Browser-Window is now visible again +// */ +// } +// }; + +// document.addEventListener('visibilitychange', handleVisibilityChange); + +// return () => { +// document.removeEventListener('visibilitychange', handleVisibilityChange); +// }; +// }, [rootStore]); diff --git a/yarn.lock b/yarn.lock index 52b13bed5..f56d9a789 100644 --- a/yarn.lock +++ b/yarn.lock @@ -181,23 +181,6 @@ resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-9.2.0.tgz#0f55b14d51408413dee17bbffb2cf06a0dd3f60b" integrity sha512-Oq1d9BGZakE/FyoEtcNeSwM7MpDO2vUBi11RWBZXf75zPsbUVWmUs03EqkRFrcgbXyKTas0BdZWC1wcuSoqSAw== -"@azure/msal-browser@^v3.28.0": - version "3.30.0" - resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-3.30.0.tgz#887c4417710d375506be041556499a0d92fc62e0" - integrity sha512-I0XlIGVdM4E9kYP5eTjgW8fgATdzwxJvQ6bm2PNiHaZhEuUz47NYw1xHthC9R+lXz4i9zbShS0VdLyxd7n0GGA== - dependencies: - "@azure/msal-common" "14.16.1" - -"@azure/msal-common@14.16.1": - version "14.16.1" - resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.16.1.tgz#d23ecce40823a4d03ad74160dc819d62e0c0a787" - integrity sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w== - -"@azure/msal-react@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@azure/msal-react/-/msal-react-2.2.0.tgz#c8928e0f33bf7a88827a1c9488b62a4bd4c92e34" - integrity sha512-2V+9JXeXyyjYNF92y5u0tU4el9px/V1+vkRuN+DtoxyiMHCtYQpJoaFdGWArh43zhz5aqQqiGW/iajPDSu3QsQ== - "@babel/code-frame@7.25.7": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.25.7.tgz#438f2c524071531d643c6f0188e1e28f130cebc7" @@ -1122,6 +1105,24 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@better-auth/core@1.3.26": + version "1.3.26" + resolved "https://registry.yarnpkg.com/@better-auth/core/-/core-1.3.26.tgz#32ffae583124437b4929672cdb007d6543aaf156" + integrity sha512-S5ooXaOcn9eLV3/JayfbMsAB5PkfoTRaRrtpb5djwvI/UAJOgLyjqhd+rObsBycovQ/nPQvMKjzyM/G1oBKngA== + dependencies: + better-call "1.0.19" + zod "^4.1.5" + +"@better-auth/utils@0.3.0", "@better-auth/utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@better-auth/utils/-/utils-0.3.0.tgz#066c5ce82bf393421547dd887b14a6899310003d" + integrity sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw== + +"@better-fetch/fetch@^1.1.18", "@better-fetch/fetch@^1.1.4": + version "1.1.18" + resolved "https://registry.yarnpkg.com/@better-fetch/fetch/-/fetch-1.1.18.tgz#4ddc65a703c65e6036947ff335e635a5a38e6225" + integrity sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA== + "@braintree/sanitize-url@6.0.2": version "6.0.2" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f" @@ -2953,6 +2954,11 @@ dependencies: "@hapi/hoek" "^9.0.0" +"@hexagon/base64@^1.1.27": + version "1.1.28" + resolved "https://registry.yarnpkg.com/@hexagon/base64/-/base64-1.1.28.tgz#7d306a97f1423829be5b27c9d388fe50e3099d48" + integrity sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw== + "@hpcc-js/wasm-graphviz@^1.7.0": version "1.12.0" resolved "https://registry.yarnpkg.com/@hpcc-js/wasm-graphviz/-/wasm-graphviz-1.12.0.tgz#fc52056b1aa4fcb884a19048b2e1e4e7a0dce7d6" @@ -3068,6 +3074,11 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" integrity sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw== +"@levischuck/tiny-cbor@^0.2.2": + version "0.2.11" + resolved "https://registry.yarnpkg.com/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz#833ddf7f3627dcb62d855d9c184061b4a1a875b3" + integrity sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow== + "@lexical/clipboard@0.32.1", "@lexical/clipboard@^0.32.1": version "0.32.1" resolved "https://registry.yarnpkg.com/@lexical/clipboard/-/clipboard-0.32.1.tgz#e7b234beb202685e8648a143dd80beb168e08157" @@ -3797,6 +3808,16 @@ "@emnapi/runtime" "^1.5.0" "@tybys/wasm-util" "^0.10.1" +"@noble/ciphers@^2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-2.0.1.tgz#fb07d6479d11fd10d7f601b6fb8fb9925b7accf7" + integrity sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g== + +"@noble/hashes@^2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -4018,6 +4039,138 @@ "@parcel/watcher-win32-ia32" "2.5.1" "@parcel/watcher-win32-x64" "2.5.1" +"@peculiar/asn1-android@^2.3.10": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.5.0.tgz#039c3b08dbd80cfb9b4c7f239c43a91247b58f98" + integrity sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-cms@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-cms/-/asn1-cms-2.5.0.tgz#3a7e857d86686898ce78efdbf481922bb805c68a" + integrity sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + "@peculiar/asn1-x509-attr" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-csr@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-csr/-/asn1-csr-2.5.0.tgz#4dd7534bd7d7db5bbbbde4d00d4836bf7e818d1c" + integrity sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-ecc@^2.3.8", "@peculiar/asn1-ecc@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-ecc/-/asn1-ecc-2.5.0.tgz#3bbeaa3443567055be112b4c7e9d5562951242cf" + integrity sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pfx@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pfx/-/asn1-pfx-2.5.0.tgz#22d12e676c063dfc6244278fe18eb75c2c121880" + integrity sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug== + dependencies: + "@peculiar/asn1-cms" "^2.5.0" + "@peculiar/asn1-pkcs8" "^2.5.0" + "@peculiar/asn1-rsa" "^2.5.0" + "@peculiar/asn1-schema" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs8@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.5.0.tgz#1939643773e928a4802813b595e324a05b453709" + integrity sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs9@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.5.0.tgz#8c5b873a721bb92b4fe758da9de1ead63165106d" + integrity sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A== + dependencies: + "@peculiar/asn1-cms" "^2.5.0" + "@peculiar/asn1-pfx" "^2.5.0" + "@peculiar/asn1-pkcs8" "^2.5.0" + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + "@peculiar/asn1-x509-attr" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-rsa@^2.3.8", "@peculiar/asn1-rsa@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-rsa/-/asn1-rsa-2.5.0.tgz#7283756ec596ccfbef23ff0e7eda0c37133ebed8" + integrity sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-schema@^2.3.8", "@peculiar/asn1-schema@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz#4e58d7c3087c4259cebf5363e092f85b9cbf0ca1" + integrity sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ== + dependencies: + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + +"@peculiar/asn1-x509-attr@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.5.0.tgz#d413597dfe097620a00780e9e2ae851b06f32aed" + integrity sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-x509@^2.3.8", "@peculiar/asn1-x509@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.5.0.tgz#305f9cd534f4b6a723d27fc59363f382debf5500" + integrity sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + +"@peculiar/x509@^1.13.0": + version "1.14.0" + resolved "https://registry.yarnpkg.com/@peculiar/x509/-/x509-1.14.0.tgz#4b1abdf7ca5e46f2cb303fba608ef0507762e84a" + integrity sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg== + dependencies: + "@peculiar/asn1-cms" "^2.5.0" + "@peculiar/asn1-csr" "^2.5.0" + "@peculiar/asn1-ecc" "^2.5.0" + "@peculiar/asn1-pkcs9" "^2.5.0" + "@peculiar/asn1-rsa" "^2.5.0" + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + pvtsutils "^1.3.6" + reflect-metadata "^0.2.2" + tslib "^2.8.1" + tsyringe "^4.10.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -5149,6 +5302,25 @@ resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== +"@simplewebauthn/browser@^13.1.2": + version "13.2.2" + resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-13.2.2.tgz#4cde38c4c6969a039c23c2a3d931ecb69f937910" + integrity sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA== + +"@simplewebauthn/server@^13.1.2": + version "13.2.2" + resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-13.2.2.tgz#42a9ebe64831b91fc1ac6d8aefc553822c7114aa" + integrity sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA== + dependencies: + "@hexagon/base64" "^1.1.27" + "@levischuck/tiny-cbor" "^0.2.2" + "@peculiar/asn1-android" "^2.3.10" + "@peculiar/asn1-ecc" "^2.3.8" + "@peculiar/asn1-rsa" "^2.3.8" + "@peculiar/asn1-schema" "^2.3.8" + "@peculiar/asn1-x509" "^2.3.8" + "@peculiar/x509" "^1.13.0" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -6694,6 +6866,15 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== +asn1js@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.6.tgz#53e002ebe00c5f7fd77c1c047c3557d7c04dce25" + integrity sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA== + dependencies: + pvtsutils "^1.3.6" + pvutils "^1.1.3" + tslib "^2.8.1" + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -6818,6 +6999,36 @@ before-after-hook@^3.0.2: resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-3.0.2.tgz#d5665a5fa8b62294a5aa0a499f933f4a1016195d" integrity sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A== +better-auth@^1.3.26: + version "1.3.26" + resolved "https://registry.yarnpkg.com/better-auth/-/better-auth-1.3.26.tgz#0828bcc4eb994539ccce3e21c4179a974248c959" + integrity sha512-umaOGmv29yF4sD6o2zlW6B0Oayko5yD/A8mKJOFDDEIoVP/pR7nJ/2KsqKy+xvBpnDsKdrZseqA8fmczPviUHw== + dependencies: + "@better-auth/core" "1.3.26" + "@better-auth/utils" "0.3.0" + "@better-fetch/fetch" "^1.1.18" + "@noble/ciphers" "^2.0.0" + "@noble/hashes" "^2.0.0" + "@simplewebauthn/browser" "^13.1.2" + "@simplewebauthn/server" "^13.1.2" + better-call "1.0.19" + defu "^6.1.4" + jose "^6.1.0" + kysely "^0.28.5" + nanostores "^1.0.1" + zod "^4.1.5" + +better-call@1.0.19: + version "1.0.19" + resolved "https://registry.yarnpkg.com/better-call/-/better-call-1.0.19.tgz#037b0d90d3ad01c434dd247b3f6778fb4f3161d4" + integrity sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw== + dependencies: + "@better-auth/utils" "^0.3.0" + "@better-fetch/fetch" "^1.1.4" + rou3 "^0.5.1" + set-cookie-parser "^2.7.1" + uncrypto "^0.1.3" + big-integer@^1.6.17: version "1.6.52" resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85" @@ -8284,6 +8495,11 @@ define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +defu@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479" + integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg== + delaunator@5: version "5.0.1" resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-5.0.1.tgz#39032b08053923e924d6094fe2cde1a99cc51278" @@ -10378,6 +10594,11 @@ joi@^17.9.2: "@sideway/formula" "^3.0.1" "@sideway/pinpoint" "^2.0.0" +jose@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.1.0.tgz#96285365689d16f2845a353964d2284bf19f464c" + integrity sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA== + jotai-scope@0.7.2: version "0.7.2" resolved "https://registry.yarnpkg.com/jotai-scope/-/jotai-scope-0.7.2.tgz#3e9ec5b743bd9f36b08b32cf5151786049bfcce7" @@ -10521,6 +10742,11 @@ kolorist@^1.8.0: resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== +kysely@^0.28.5: + version "0.28.7" + resolved "https://registry.yarnpkg.com/kysely/-/kysely-0.28.7.tgz#461d160865825f89173a7f814489e92a91b13864" + integrity sha512-u/cAuTL4DRIiO2/g4vNGRgklEKNIj5Q3CG7RoUB5DV5SfEC2hMvPxKi0GWPmnzwL2ryIeud2VTcEEmqzTzEPNw== + langium@3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/langium/-/langium-3.3.1.tgz#da745a40d5ad8ee565090fed52eaee643be4e591" @@ -12113,6 +12339,11 @@ nanoid@^3.3.11: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== +nanostores@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/nanostores/-/nanostores-1.0.1.tgz#bcc352ff7bf622e9274a3109c92e823a452d6028" + integrity sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw== + napi-build-utils@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e" @@ -13375,6 +13606,18 @@ pupa@^3.1.0: dependencies: escape-goat "^4.0.0" +pvtsutils@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.6.tgz#ec46e34db7422b9e4fdc5490578c1883657d6001" + integrity sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg== + dependencies: + tslib "^2.8.1" + +pvutils@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" + integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== + pwacompat@2.0.17: version "2.0.17" resolved "https://registry.yarnpkg.com/pwacompat/-/pwacompat-2.0.17.tgz#707959ff97f239bf1fe7b260b63aeea416a15eab" @@ -13790,6 +14033,11 @@ recma-stringify@^1.0.0: unified "^11.0.0" vfile "^6.0.0" +reflect-metadata@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" + integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== + regenerate-unicode-properties@^10.2.2: version "10.2.2" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz#aa113812ba899b630658c7623466be71e1f86f66" @@ -14098,6 +14346,11 @@ rollup@^4.20.0, rollup@^4.43.0: "@rollup/rollup-win32-x64-msvc" "4.50.2" fsevents "~2.3.2" +rou3@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/rou3/-/rou3-0.5.1.tgz#414d1123631f45474df743c9fc904d757bb2e43c" + integrity sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ== + roughjs@4.6.4: version "4.6.4" resolved "https://registry.yarnpkg.com/roughjs/-/roughjs-4.6.4.tgz#b6f39b44645854a6e0a4a28b078368701eb7f939" @@ -14335,6 +14588,11 @@ serve-static@1.16.2: parseurl "~1.3.3" send "0.19.0" +set-cookie-parser@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz#3016f150072202dfbe90fadee053573cc89d2943" + integrity sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ== + set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -15057,11 +15315,23 @@ ts-node@^10.9.2: v8-compile-cache-lib "^3.0.1" yn "3.1.1" -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.6.0: +tslib@^1.9.3: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.6.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tsyringe@^4.10.0: + version "4.10.0" + resolved "https://registry.yarnpkg.com/tsyringe/-/tsyringe-4.10.0.tgz#d0c95815d584464214060285eaaadd94aa03299c" + integrity sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw== + dependencies: + tslib "^1.9.3" + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" @@ -15126,6 +15396,11 @@ ufo@^1.6.1: resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b" integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== +uncrypto@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/uncrypto/-/uncrypto-0.1.3.tgz#e1288d609226f2d02d8d69ee861fa20d8348ef2b" + integrity sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q== + undici-types@~7.12.0: version "7.12.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.12.0.tgz#15c5c7475c2a3ba30659529f5cdb4674b622fafb" @@ -15937,6 +16212,11 @@ zip-stream@^4.1.0: compress-commons "^4.1.2" readable-stream "^3.6.0" +zod@^4.1.5: + version "4.1.11" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5" + integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg== + zustand@^4.3.2: version "4.5.7" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"