feat: OIDC (#564)
* feat: oidc feat: organization switcher refactor: org context feat: invitations GLM * feat: link current account * refactor: own page for sso registration * feat: per-user account management * refactor: code style * refactor: user existing check * refactor: restrict provider configuration to super admins only * refactor: cleanup / pr review * chore: fix lint issues * chore: pr feedbacks * test(e2e): automated tests for OIDC * fix: check url first for sso provider identification * fix: prevent oidc provider to be named "credential"
This commit is contained in:
parent
b2d2f28b40
commit
7a3932f969
79 changed files with 12915 additions and 7875 deletions
10
.github/workflows/e2e.yml
vendored
10
.github/workflows/e2e.yml
vendored
|
|
@ -51,6 +51,7 @@ jobs:
|
|||
echo "SERVER_IP=localhost" >> .env.local
|
||||
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
|
||||
echo "BASE_URL=http://localhost:4096" >> .env.local
|
||||
echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local
|
||||
|
||||
- name: Start zerobyte-e2e service
|
||||
run: bun run start:e2e -- -d
|
||||
|
|
@ -59,9 +60,13 @@ jobs:
|
|||
run: |
|
||||
timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done'
|
||||
|
||||
- name: Wait for Dex to be ready
|
||||
run: |
|
||||
timeout 30s bash -c 'until curl -sf http://localhost:5557/dex/.well-known/openid-configuration; do echo "Waiting for Dex..." && sleep 2; done'
|
||||
|
||||
- name: Print docker logs if failed to start
|
||||
if: failure()
|
||||
run: docker logs zerobyte || true
|
||||
run: docker compose logs zerobyte-e2e dex || true
|
||||
|
||||
- name: Make playwright directory writable
|
||||
run: sudo chmod 777 playwright/data/zerobyte.db
|
||||
|
|
@ -73,7 +78,8 @@ jobs:
|
|||
if: always()
|
||||
run: |
|
||||
tree playwright
|
||||
docker logs zerobyte > playwright-report/container-logs.txt || true
|
||||
docker compose logs zerobyte-e2e > playwright-report/container-logs.txt || true
|
||||
docker compose logs dex > playwright-report/dex-logs.txt || true
|
||||
|
||||
- name: Debug - print content of /test-data in container
|
||||
if: failure()
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -28,6 +28,7 @@ node_modules/
|
|||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
/playwright/restic.pass
|
||||
|
||||
playwright/.auth
|
||||
playwright/temp
|
||||
|
|
@ -39,3 +40,4 @@ openapi-ts-error-*.log
|
|||
.output
|
||||
tmp/
|
||||
qa-output
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@
|
|||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 120,
|
||||
"useTabs": true,
|
||||
"endOfLine": "lf"
|
||||
"endOfLine": "lf",
|
||||
"ignorePatterns": ["*.gen.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# AGENTS.md
|
||||
|
||||
## Important instructions
|
||||
|
||||
- Never create migration files manually. Always use the provided command to generate migrations
|
||||
|
|
@ -62,3 +60,8 @@ bunx oxfmt format --write <path>
|
|||
# Lint
|
||||
bun run lint
|
||||
```
|
||||
|
||||
### Invalidation
|
||||
|
||||
The frontend has an automatic invalidation setup which runs after every mutation.
|
||||
Do not implement any invalidation logic in the frontend.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,286 +1,289 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { createSseClient } from "../core/serverSentEvents.gen";
|
||||
import type { HttpMethod } from "../core/types.gen";
|
||||
import { getValidRequestBody } from "../core/utils.gen";
|
||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen";
|
||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||
import type { HttpMethod } from '../core/types.gen';
|
||||
import { getValidRequestBody } from '../core/utils.gen';
|
||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
|
||||
import {
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from "./utils.gen";
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from './utils.gen';
|
||||
|
||||
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
};
|
||||
|
||||
export const createClient = (config: Config = {}): Client => {
|
||||
let _config = mergeConfigs(createConfig(), config);
|
||||
let _config = mergeConfigs(createConfig(), config);
|
||||
|
||||
const getConfig = (): Config => ({ ..._config });
|
||||
const getConfig = (): Config => ({ ..._config });
|
||||
|
||||
const setConfig = (config: Config): Config => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
const setConfig = (config: Config): Config => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
|
||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||
|
||||
const beforeRequest = async (options: RequestOptions) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined,
|
||||
};
|
||||
const beforeRequest = async (options: RequestOptions) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined,
|
||||
};
|
||||
|
||||
if (opts.security) {
|
||||
await setAuthParams({
|
||||
...opts,
|
||||
security: opts.security,
|
||||
});
|
||||
}
|
||||
if (opts.security) {
|
||||
await setAuthParams({
|
||||
...opts,
|
||||
security: opts.security,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||
}
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||
}
|
||||
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === "") {
|
||||
opts.headers.delete("Content-Type");
|
||||
}
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === '') {
|
||||
opts.headers.delete('Content-Type');
|
||||
}
|
||||
|
||||
const url = buildUrl(opts);
|
||||
const url = buildUrl(opts);
|
||||
|
||||
return { opts, url };
|
||||
};
|
||||
return { opts, url };
|
||||
};
|
||||
|
||||
const request: Client["request"] = async (options) => {
|
||||
// @ts-expect-error
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: "follow",
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
const request: Client['request'] = async (options) => {
|
||||
// @ts-expect-error
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: 'follow',
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
|
||||
let request = new Request(url, requestInit);
|
||||
let request = new Request(url, requestInit);
|
||||
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = opts.fetch!;
|
||||
let response: Response;
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = opts.fetch!;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await _fetch(request);
|
||||
} catch (error) {
|
||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||
let finalError = error;
|
||||
try {
|
||||
response = await _fetch(request);
|
||||
} catch (error) {
|
||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
|
||||
}
|
||||
}
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as unknown);
|
||||
finalError = finalError || ({} as unknown);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// Return error response
|
||||
return opts.responseStyle === "data"
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response: undefined as any,
|
||||
};
|
||||
}
|
||||
// Return error response
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response: undefined as any,
|
||||
};
|
||||
}
|
||||
|
||||
for (const fn of interceptors.response.fns) {
|
||||
if (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
for (const fn of interceptors.response.fns) {
|
||||
if (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
|
||||
if (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === 'auto'
|
||||
? getParseAs(response.headers.get('Content-Type'))
|
||||
: opts.parseAs) ?? 'json';
|
||||
|
||||
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
|
||||
let emptyData: any;
|
||||
switch (parseAs) {
|
||||
case "arrayBuffer":
|
||||
case "blob":
|
||||
case "text":
|
||||
emptyData = await response[parseAs]();
|
||||
break;
|
||||
case "formData":
|
||||
emptyData = new FormData();
|
||||
break;
|
||||
case "stream":
|
||||
emptyData = response.body;
|
||||
break;
|
||||
case "json":
|
||||
default:
|
||||
emptyData = {};
|
||||
break;
|
||||
}
|
||||
return opts.responseStyle === "data"
|
||||
? emptyData
|
||||
: {
|
||||
data: emptyData,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
|
||||
let emptyData: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'text':
|
||||
emptyData = await response[parseAs]();
|
||||
break;
|
||||
case 'formData':
|
||||
emptyData = new FormData();
|
||||
break;
|
||||
case 'stream':
|
||||
emptyData = response.body;
|
||||
break;
|
||||
case 'json':
|
||||
default:
|
||||
emptyData = {};
|
||||
break;
|
||||
}
|
||||
return opts.responseStyle === 'data'
|
||||
? emptyData
|
||||
: {
|
||||
data: emptyData,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case "arrayBuffer":
|
||||
case "blob":
|
||||
case "formData":
|
||||
case "text":
|
||||
data = await response[parseAs]();
|
||||
break;
|
||||
case "json": {
|
||||
// Some servers return 200 with no Content-Length and empty body.
|
||||
// response.json() would throw; read as text and parse if non-empty.
|
||||
const text = await response.text();
|
||||
data = text ? JSON.parse(text) : {};
|
||||
break;
|
||||
}
|
||||
case "stream":
|
||||
return opts.responseStyle === "data"
|
||||
? response.body
|
||||
: {
|
||||
data: response.body,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'formData':
|
||||
case 'text':
|
||||
data = await response[parseAs]();
|
||||
break;
|
||||
case 'json': {
|
||||
// Some servers return 200 with no Content-Length and empty body.
|
||||
// response.json() would throw; read as text and parse if non-empty.
|
||||
const text = await response.text();
|
||||
data = text ? JSON.parse(text) : {};
|
||||
break;
|
||||
}
|
||||
case 'stream':
|
||||
return opts.responseStyle === 'data'
|
||||
? response.body
|
||||
: {
|
||||
data: response.body,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
if (parseAs === "json") {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
if (parseAs === 'json') {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
|
||||
if (opts.responseTransformer) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
if (opts.responseTransformer) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
return opts.responseStyle === "data"
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
return opts.responseStyle === 'data'
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
const error = jsonError ?? textError;
|
||||
let finalError = error;
|
||||
const error = jsonError ?? textError;
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, response, request, opts)) as string;
|
||||
}
|
||||
}
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, response, request, opts)) as string;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as string);
|
||||
finalError = finalError || ({} as string);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// TODO: we probably want to return error and improve types
|
||||
return opts.responseStyle === "data"
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
// TODO: we probably want to return error and improve types
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method });
|
||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||
request({ ...options, method });
|
||||
|
||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
headers: opts.headers as unknown as Record<string, string>,
|
||||
method,
|
||||
onRequest: async (url, init) => {
|
||||
let request = new Request(url, init);
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
},
|
||||
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
|
||||
url,
|
||||
});
|
||||
};
|
||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
headers: opts.headers as unknown as Record<string, string>,
|
||||
method,
|
||||
onRequest: async (url, init) => {
|
||||
let request = new Request(url, init);
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
},
|
||||
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
connect: makeMethodFn("CONNECT"),
|
||||
delete: makeMethodFn("DELETE"),
|
||||
get: makeMethodFn("GET"),
|
||||
getConfig,
|
||||
head: makeMethodFn("HEAD"),
|
||||
interceptors,
|
||||
options: makeMethodFn("OPTIONS"),
|
||||
patch: makeMethodFn("PATCH"),
|
||||
post: makeMethodFn("POST"),
|
||||
put: makeMethodFn("PUT"),
|
||||
request,
|
||||
setConfig,
|
||||
sse: {
|
||||
connect: makeSseFn("CONNECT"),
|
||||
delete: makeSseFn("DELETE"),
|
||||
get: makeSseFn("GET"),
|
||||
head: makeSseFn("HEAD"),
|
||||
options: makeSseFn("OPTIONS"),
|
||||
patch: makeSseFn("PATCH"),
|
||||
post: makeSseFn("POST"),
|
||||
put: makeSseFn("PUT"),
|
||||
trace: makeSseFn("TRACE"),
|
||||
},
|
||||
trace: makeMethodFn("TRACE"),
|
||||
} as Client;
|
||||
return {
|
||||
buildUrl,
|
||||
connect: makeMethodFn('CONNECT'),
|
||||
delete: makeMethodFn('DELETE'),
|
||||
get: makeMethodFn('GET'),
|
||||
getConfig,
|
||||
head: makeMethodFn('HEAD'),
|
||||
interceptors,
|
||||
options: makeMethodFn('OPTIONS'),
|
||||
patch: makeMethodFn('PATCH'),
|
||||
post: makeMethodFn('POST'),
|
||||
put: makeMethodFn('PUT'),
|
||||
request,
|
||||
setConfig,
|
||||
sse: {
|
||||
connect: makeSseFn('CONNECT'),
|
||||
delete: makeSseFn('DELETE'),
|
||||
get: makeSseFn('GET'),
|
||||
head: makeSseFn('HEAD'),
|
||||
options: makeSseFn('OPTIONS'),
|
||||
patch: makeSseFn('PATCH'),
|
||||
post: makeSseFn('POST'),
|
||||
put: makeSseFn('PUT'),
|
||||
trace: makeSseFn('TRACE'),
|
||||
},
|
||||
trace: makeMethodFn('TRACE'),
|
||||
} as Client;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,176 +1,184 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth } from "../core/auth.gen";
|
||||
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen";
|
||||
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen";
|
||||
import type { Middleware } from "./utils.gen";
|
||||
import type { Auth } from '../core/auth.gen';
|
||||
import type {
|
||||
ServerSentEventsOptions,
|
||||
ServerSentEventsResult,
|
||||
} from '../core/serverSentEvents.gen';
|
||||
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
|
||||
import type { Middleware } from './utils.gen';
|
||||
|
||||
export type ResponseStyle = "data" | "fields";
|
||||
export type ResponseStyle = 'data' | 'fields';
|
||||
|
||||
export interface Config<T extends ClientOptions = ClientOptions>
|
||||
extends Omit<RequestInit, "body" | "headers" | "method">, CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T["baseUrl"];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||
* options won't have any effect.
|
||||
*
|
||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||
*/
|
||||
next?: never;
|
||||
/**
|
||||
* Return the response data parsed in a specified format. By default, `auto`
|
||||
* will infer the appropriate method from the `Content-Type` response header.
|
||||
* You can override this behavior with any of the {@link Body} methods.
|
||||
* Select `stream` if you don't want to parse response data at all.
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text";
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T["throwOnError"];
|
||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T['baseUrl'];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||
* options won't have any effect.
|
||||
*
|
||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||
*/
|
||||
next?: never;
|
||||
/**
|
||||
* Return the response data parsed in a specified format. By default, `auto`
|
||||
* will infer the appropriate method from the `Content-Type` response header.
|
||||
* You can override this behavior with any of the {@link Body} methods.
|
||||
* Select `stream` if you don't want to parse response data at all.
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T['throwOnError'];
|
||||
}
|
||||
|
||||
export interface RequestOptions<
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
>
|
||||
extends
|
||||
Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
"onRequest" | "onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
extends
|
||||
Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
| 'onRequest'
|
||||
| 'onSseError'
|
||||
| 'onSseEvent'
|
||||
| 'sseDefaultRetryDelay'
|
||||
| 'sseMaxRetryAttempts'
|
||||
| 'sseMaxRetryDelay'
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
}
|
||||
|
||||
export interface ResolvedRequestOptions<
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
serializedBody?: string;
|
||||
serializedBody?: string;
|
||||
}
|
||||
|
||||
export type RequestResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = ThrowOnError extends true
|
||||
? Promise<
|
||||
TResponseStyle extends "data"
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends "data"
|
||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||
}
|
||||
) & {
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>;
|
||||
? Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||
}
|
||||
) & {
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
}
|
||||
|
||||
type MethodFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type SseFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||
|
||||
type RequestFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
|
||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">,
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type BuildUrlFn = <
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
>(
|
||||
options: TData & Options<TData>,
|
||||
options: TData & Options<TData>,
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -182,23 +190,26 @@ export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn>
|
|||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||
override?: Config<ClientOptions & T>,
|
||||
override?: Config<ClientOptions & T>,
|
||||
) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export interface TDataShape {
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Options<
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = "fields",
|
||||
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, "url">);
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = OmitKeys<
|
||||
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||
'body' | 'path' | 'query' | 'url'
|
||||
> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||
|
|
|
|||
|
|
@ -1,290 +1,317 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { getAuthToken } from "../core/auth.gen";
|
||||
import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
||||
import { jsonBodySerializer } from "../core/bodySerializer.gen";
|
||||
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen";
|
||||
import { getUrl } from "../core/utils.gen";
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen";
|
||||
import { getAuthToken } from '../core/auth.gen';
|
||||
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||
import {
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from '../core/pathSerializer.gen';
|
||||
import { getUrl } from '../core/utils.gen';
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||
|
||||
export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === "object") {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
export const createQuerySerializer = <T = unknown>({
|
||||
parameters = {},
|
||||
...args
|
||||
}: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === 'object') {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = parameters[name] || args;
|
||||
const options = parameters[name] || args;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: "form",
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === "object") {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: "deepObject",
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join("&");
|
||||
};
|
||||
return querySerializer;
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'form',
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === 'object') {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'deepObject',
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join('&');
|
||||
};
|
||||
return querySerializer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers parseAs value from provided Content-Type header.
|
||||
*/
|
||||
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
|
||||
if (!contentType) {
|
||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||
// which is effectively the same as the 'stream' option.
|
||||
return "stream";
|
||||
}
|
||||
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
|
||||
if (!contentType) {
|
||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||
// which is effectively the same as the 'stream' option.
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
const cleanContent = contentType.split(";")[0]?.trim();
|
||||
const cleanContent = contentType.split(';')[0]?.trim();
|
||||
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
|
||||
return "json";
|
||||
}
|
||||
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (cleanContent === "multipart/form-data") {
|
||||
return "formData";
|
||||
}
|
||||
if (cleanContent === 'multipart/form-data') {
|
||||
return 'formData';
|
||||
}
|
||||
|
||||
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
|
||||
return "blob";
|
||||
}
|
||||
if (
|
||||
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
|
||||
) {
|
||||
return 'blob';
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith("text/")) {
|
||||
return "text";
|
||||
}
|
||||
if (cleanContent.startsWith('text/')) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return;
|
||||
return;
|
||||
};
|
||||
|
||||
const checkForExistence = (
|
||||
options: Pick<RequestOptions, "auth" | "query"> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
): boolean => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
options.headers.has(name) ||
|
||||
options.query?.[name] ||
|
||||
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, "security"> &
|
||||
Pick<RequestOptions, "auth" | "query"> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
if (checkForExistence(options, auth.name)) {
|
||||
continue;
|
||||
}
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, 'security'> &
|
||||
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
if (checkForExistence(options, auth.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = await getAuthToken(auth, options.auth);
|
||||
const token = await getAuthToken(auth, options.auth);
|
||||
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = auth.name ?? "Authorization";
|
||||
const name = auth.name ?? 'Authorization';
|
||||
|
||||
switch (auth.in) {
|
||||
case "query":
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case "cookie":
|
||||
options.headers.append("Cookie", `${name}=${token}`);
|
||||
break;
|
||||
case "header":
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
switch (auth.in) {
|
||||
case 'query':
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case 'cookie':
|
||||
options.headers.append('Cookie', `${name}=${token}`);
|
||||
break;
|
||||
case 'header':
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUrl: Client["buildUrl"] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === "function"
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === 'function'
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
|
||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith("/")) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith('/')) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
};
|
||||
|
||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
export const mergeHeaders = (
|
||||
...headers: Array<Required<Config>['headers'] | undefined>
|
||||
): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
||||
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
||||
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e. their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e. their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
};
|
||||
|
||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||
error: Err,
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
error: Err,
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Err | Promise<Err>;
|
||||
|
||||
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
||||
|
||||
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>;
|
||||
type ResInterceptor<Res, Req, Options> = (
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Res | Promise<Res>;
|
||||
|
||||
class Interceptors<Interceptor> {
|
||||
fns: Array<Interceptor | null> = [];
|
||||
fns: Array<Interceptor | null> = [];
|
||||
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === "number") {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === 'number') {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
|
||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Middleware<Req, Res, Err, Options> {
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
}
|
||||
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||
Req,
|
||||
Res,
|
||||
Err,
|
||||
Options
|
||||
> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
});
|
||||
|
||||
const defaultQuerySerializer = createQuerySerializer({
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: "form",
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: "deepObject",
|
||||
},
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: 'form',
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: 'deepObject',
|
||||
},
|
||||
});
|
||||
|
||||
const defaultHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: "auto",
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: 'auto',
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,39 +4,39 @@
|
|||
export type AuthToken = string | undefined;
|
||||
|
||||
export interface Auth {
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: "header" | "query" | "cookie";
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: "basic" | "bearer";
|
||||
type: "apiKey" | "http";
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: 'basic' | 'bearer';
|
||||
type: 'apiKey' | 'http';
|
||||
}
|
||||
|
||||
export const getAuthToken = async (
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
): Promise<string | undefined> => {
|
||||
const token = typeof callback === "function" ? await callback(auth) : callback;
|
||||
const token = typeof callback === 'function' ? await callback(auth) : callback;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.scheme === "bearer") {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
if (auth.scheme === 'bearer') {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (auth.scheme === "basic") {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
if (auth.scheme === 'basic') {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
|
||||
return token;
|
||||
return token;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,83 +1,85 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen";
|
||||
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
|
||||
|
||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||
|
||||
export type BodySerializer = (body: any) => any;
|
||||
|
||||
type QuerySerializerOptionsObject = {
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
};
|
||||
|
||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
};
|
||||
|
||||
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
||||
if (typeof value === "string" || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
if (typeof value === 'string' || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
||||
if (typeof value === "string") {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): FormData => {
|
||||
const data = new FormData();
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||
body: T,
|
||||
): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: <T>(body: T): string =>
|
||||
JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
|
||||
bodySerializer: <T>(body: T): string =>
|
||||
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
|
||||
const data = new URLSearchParams();
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
|
||||
const data = new URLSearchParams();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data.toString();
|
||||
},
|
||||
return data.toString();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,170 +1,170 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
type Slot = "body" | "headers" | "path" | "query";
|
||||
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||
|
||||
export type Field =
|
||||
| {
|
||||
in: Exclude<Slot, "body">;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, "body">;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
| {
|
||||
in: Exclude<Slot, 'body'>;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, 'body'>;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
|
||||
export interface Fields {
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
}
|
||||
|
||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||
|
||||
const extraPrefixesMap: Record<string, Slot> = {
|
||||
$body_: "body",
|
||||
$headers_: "headers",
|
||||
$path_: "path",
|
||||
$query_: "query",
|
||||
$body_: 'body',
|
||||
$headers_: 'headers',
|
||||
$path_: 'path',
|
||||
$query_: 'query',
|
||||
};
|
||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
type KeyMap = Map<
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
>;
|
||||
|
||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
|
||||
for (const config of fields) {
|
||||
if ("in" in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ("key" in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
for (const config of fields) {
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ('key' in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
return map;
|
||||
};
|
||||
|
||||
interface Params {
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === "object" && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
path: {},
|
||||
query: {},
|
||||
};
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
path: {},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const map = buildKeyMap(fields);
|
||||
const map = buildKeyMap(fields);
|
||||
|
||||
let config: FieldsConfig[number] | undefined;
|
||||
let config: FieldsConfig[number] | undefined;
|
||||
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("in" in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||
} else if ("allowExtra" in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||
} else if ('allowExtra' in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stripEmptySlots(params);
|
||||
stripEmptySlots(params);
|
||||
|
||||
return params;
|
||||
return params;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,165 +4,169 @@
|
|||
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
||||
|
||||
interface SerializePrimitiveOptions {
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SerializerOptions<T> {
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
}
|
||||
|
||||
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
|
||||
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||
type MatrixStyle = "label" | "matrix" | "simple";
|
||||
export type ObjectStyle = "form" | "deepObject";
|
||||
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||
export type ObjectStyle = 'form' | 'deepObject';
|
||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||
|
||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
value: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case "label":
|
||||
return ".";
|
||||
case "matrix":
|
||||
return ";";
|
||||
case "simple":
|
||||
return ",";
|
||||
default:
|
||||
return "&";
|
||||
}
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case "form":
|
||||
return ",";
|
||||
case "pipeDelimited":
|
||||
return "|";
|
||||
case "spaceDelimited":
|
||||
return "%20";
|
||||
default:
|
||||
return ",";
|
||||
}
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return ',';
|
||||
case 'pipeDelimited':
|
||||
return '|';
|
||||
case 'spaceDelimited':
|
||||
return '%20';
|
||||
default:
|
||||
return ',';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||
switch (style) {
|
||||
case "label":
|
||||
return ".";
|
||||
case "matrix":
|
||||
return ";";
|
||||
case "simple":
|
||||
return ",";
|
||||
default:
|
||||
return "&";
|
||||
}
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeArrayParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||
value: unknown[];
|
||||
value: unknown[];
|
||||
}) => {
|
||||
if (!explode) {
|
||||
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(
|
||||
separatorArrayNoExplode(style),
|
||||
);
|
||||
switch (style) {
|
||||
case "label":
|
||||
return `.${joinedValues}`;
|
||||
case "matrix":
|
||||
return `;${name}=${joinedValues}`;
|
||||
case "simple":
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
if (!explode) {
|
||||
const joinedValues = (
|
||||
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||
).join(separatorArrayNoExplode(style));
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
case 'simple':
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === "label" || style === "simple") {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === 'label' || style === 'simple') {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
|
||||
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
export const serializePrimitiveParam = ({
|
||||
allowReserved,
|
||||
name,
|
||||
value,
|
||||
}: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
throw new Error(
|
||||
"Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.",
|
||||
);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||
);
|
||||
}
|
||||
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
};
|
||||
|
||||
export const serializeObjectParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
}) => {
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (style !== "deepObject" && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||
});
|
||||
const joinedValues = values.join(",");
|
||||
switch (style) {
|
||||
case "form":
|
||||
return `${name}=${joinedValues}`;
|
||||
case "label":
|
||||
return `.${joinedValues}`;
|
||||
case "matrix":
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
if (style !== 'deepObject' && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||
});
|
||||
const joinedValues = values.join(',');
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return `${name}=${joinedValues}`;
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === "deepObject" ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues;
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,109 +4,115 @@
|
|||
/**
|
||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||
*/
|
||||
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type JsonValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||
*/
|
||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely stringifies a value and parses it back into a JsonValue.
|
||||
*/
|
||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects plain objects (including objects with a null prototype).
|
||||
*/
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||
*/
|
||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
const result: Record<string, JsonValue> = {};
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||
*/
|
||||
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
||||
return undefined;
|
||||
}
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,240 +1,244 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Config } from "./types.gen";
|
||||
import type { Config } from './types.gen';
|
||||
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
|
||||
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit["body"];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
|
||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit['body'];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export interface StreamEvent<TData = unknown> {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
||||
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>;
|
||||
stream: AsyncGenerator<
|
||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||
TReturn,
|
||||
TNext
|
||||
>;
|
||||
};
|
||||
|
||||
export const createSseClient = <TData = unknown>({
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||
let lastEventId: string | undefined;
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
attempt++;
|
||||
attempt++;
|
||||
|
||||
const headers =
|
||||
options.headers instanceof Headers
|
||||
? options.headers
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
const headers =
|
||||
options.headers instanceof Headers
|
||||
? options.headers
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set("Last-Event-ID", lastEventId);
|
||||
}
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: "follow",
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: 'follow',
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
|
||||
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
||||
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.body) throw new Error("No body in SSE response");
|
||||
if (!response.body) throw new Error('No body in SSE response');
|
||||
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
|
||||
let buffer = "";
|
||||
let buffer = '';
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", abortHandler);
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
const chunks = buffer.split("\n\n");
|
||||
buffer = chunks.pop() ?? "";
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split("\n");
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split('\n');
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ""));
|
||||
} else if (line.startsWith("event:")) {
|
||||
eventName = line.replace(/^event:\s*/, "");
|
||||
} else if (line.startsWith("id:")) {
|
||||
lastEventId = line.replace(/^id:\s*/, "");
|
||||
} else if (line.startsWith("retry:")) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventName = line.replace(/^event:\s*/, '');
|
||||
} else if (line.startsWith('id:')) {
|
||||
lastEventId = line.replace(/^id:\s*/, '');
|
||||
} else if (line.startsWith('retry:')) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join("\n");
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
|
||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = createStream();
|
||||
const stream = createStream();
|
||||
|
||||
return { stream };
|
||||
return { stream };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,87 +1,105 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth, AuthToken } from "./auth.gen";
|
||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen";
|
||||
import type { Auth, AuthToken } from './auth.gen';
|
||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
|
||||
|
||||
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
||||
export type HttpMethod =
|
||||
| 'connect'
|
||||
| 'delete'
|
||||
| 'get'
|
||||
| 'head'
|
||||
| 'options'
|
||||
| 'patch'
|
||||
| 'post'
|
||||
| 'put'
|
||||
| 'trace';
|
||||
|
||||
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
export type Client<
|
||||
RequestFn = never,
|
||||
Config = unknown,
|
||||
MethodFn = never,
|
||||
BuildUrlFn = never,
|
||||
SseFn = never,
|
||||
> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
} & {
|
||||
[K in HttpMethod]: MethodFn;
|
||||
[K in HttpMethod]: MethodFn;
|
||||
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit["headers"]
|
||||
| Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* A function validating request data. This is useful if you want to ensure
|
||||
* the request conforms to the desired shape, so it can be safely sent to
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function transforming response data before it's returned. This is useful
|
||||
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||
*/
|
||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function validating response data. This is useful if you want to ensure
|
||||
* the response conforms to the desired shape, so it can be safely passed to
|
||||
* the transformers and returned to the user.
|
||||
*/
|
||||
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit['headers']
|
||||
| Record<
|
||||
string,
|
||||
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
|
||||
>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* A function validating request data. This is useful if you want to ensure
|
||||
* the request conforms to the desired shape, so it can be safely sent to
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function transforming response data before it's returned. This is useful
|
||||
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||
*/
|
||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function validating response data. This is useful if you want to ensure
|
||||
* the response conforms to the desired shape, so it can be safely passed to
|
||||
* the transformers and returned to the user.
|
||||
*/
|
||||
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||
? true
|
||||
: [T] extends [never | undefined]
|
||||
? [undefined] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
? true
|
||||
: [T] extends [never | undefined]
|
||||
? [undefined] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
|
||||
export type OmitNever<T extends Record<string, unknown>> = {
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,138 +1,141 @@
|
|||
// @ts-nocheck
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen";
|
||||
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||
import {
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from "./pathSerializer.gen";
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export interface PathSerializer {
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||
|
||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = "simple";
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = 'simple';
|
||||
|
||||
if (name.endsWith("*")) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
if (name.endsWith('*')) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
|
||||
if (name.startsWith(".")) {
|
||||
name = name.substring(1);
|
||||
style = "label";
|
||||
} else if (name.startsWith(";")) {
|
||||
name = name.substring(1);
|
||||
style = "matrix";
|
||||
}
|
||||
if (name.startsWith('.')) {
|
||||
name = name.substring(1);
|
||||
style = 'label';
|
||||
} else if (name.startsWith(';')) {
|
||||
name = name.substring(1);
|
||||
style = 'matrix';
|
||||
}
|
||||
|
||||
const value = path[name];
|
||||
const value = path[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (style === "matrix") {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (style === 'matrix') {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string));
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
const replaceValue = encodeURIComponent(
|
||||
style === 'label' ? `.${value as string}` : (value as string),
|
||||
);
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getUrl = ({
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
}) => {
|
||||
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? "") + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : "";
|
||||
if (search.startsWith("?")) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? '') + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : '';
|
||||
if (search.startsWith('?')) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export function getValidRequestBody(options: {
|
||||
body?: unknown;
|
||||
bodySerializer?: BodySerializer | null;
|
||||
serializedBody?: unknown;
|
||||
body?: unknown;
|
||||
bodySerializer?: BodySerializer | null;
|
||||
serializedBody?: unknown;
|
||||
}) {
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
|
||||
if (isSerializedBody) {
|
||||
if ("serializedBody" in options) {
|
||||
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== "";
|
||||
if (isSerializedBody) {
|
||||
if ('serializedBody' in options) {
|
||||
const hasSerializedBody =
|
||||
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||
|
||||
return hasSerializedBody ? options.serializedBody : null;
|
||||
}
|
||||
return hasSerializedBody ? options.serializedBody : null;
|
||||
}
|
||||
|
||||
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||
return options.body !== "" ? options.body : null;
|
||||
}
|
||||
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||
return options.body !== '' ? options.body : null;
|
||||
}
|
||||
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
|
||||
// no body was provided
|
||||
return undefined;
|
||||
// no body was provided
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,22 +13,28 @@ export {
|
|||
deleteRepository,
|
||||
deleteSnapshot,
|
||||
deleteSnapshots,
|
||||
deleteSsoInvitation,
|
||||
deleteSsoProvider,
|
||||
deleteUserAccount,
|
||||
deleteVolume,
|
||||
devPanelExec,
|
||||
downloadResticPassword,
|
||||
dumpSnapshot,
|
||||
getAdminUsers,
|
||||
getBackupProgress,
|
||||
getBackupSchedule,
|
||||
getBackupScheduleForVolume,
|
||||
getDevPanel,
|
||||
getMirrorCompatibility,
|
||||
getNotificationDestination,
|
||||
getPublicSsoProviders,
|
||||
getRegistrationStatus,
|
||||
getRepository,
|
||||
getRepositoryStats,
|
||||
getScheduleMirrors,
|
||||
getScheduleNotifications,
|
||||
getSnapshotDetails,
|
||||
getSsoSettings,
|
||||
getStatus,
|
||||
getSystemInfo,
|
||||
getUpdates,
|
||||
|
|
@ -63,6 +69,7 @@ export {
|
|||
updateRepository,
|
||||
updateScheduleMirrors,
|
||||
updateScheduleNotifications,
|
||||
updateSsoProviderAutoLinking,
|
||||
updateVolume,
|
||||
} from "./sdk.gen";
|
||||
export type {
|
||||
|
|
@ -102,6 +109,15 @@ export type {
|
|||
DeleteSnapshotsData,
|
||||
DeleteSnapshotsResponse,
|
||||
DeleteSnapshotsResponses,
|
||||
DeleteSsoInvitationData,
|
||||
DeleteSsoInvitationErrors,
|
||||
DeleteSsoInvitationResponses,
|
||||
DeleteSsoProviderData,
|
||||
DeleteSsoProviderErrors,
|
||||
DeleteSsoProviderResponses,
|
||||
DeleteUserAccountData,
|
||||
DeleteUserAccountErrors,
|
||||
DeleteUserAccountResponses,
|
||||
DeleteVolumeData,
|
||||
DeleteVolumeResponse,
|
||||
DeleteVolumeResponses,
|
||||
|
|
@ -115,6 +131,9 @@ export type {
|
|||
DumpSnapshotData,
|
||||
DumpSnapshotResponse,
|
||||
DumpSnapshotResponses,
|
||||
GetAdminUsersData,
|
||||
GetAdminUsersResponse,
|
||||
GetAdminUsersResponses,
|
||||
GetBackupProgressData,
|
||||
GetBackupProgressResponse,
|
||||
GetBackupProgressResponses,
|
||||
|
|
@ -134,6 +153,9 @@ export type {
|
|||
GetNotificationDestinationErrors,
|
||||
GetNotificationDestinationResponse,
|
||||
GetNotificationDestinationResponses,
|
||||
GetPublicSsoProvidersData,
|
||||
GetPublicSsoProvidersResponse,
|
||||
GetPublicSsoProvidersResponses,
|
||||
GetRegistrationStatusData,
|
||||
GetRegistrationStatusResponse,
|
||||
GetRegistrationStatusResponses,
|
||||
|
|
@ -152,6 +174,9 @@ export type {
|
|||
GetSnapshotDetailsData,
|
||||
GetSnapshotDetailsResponse,
|
||||
GetSnapshotDetailsResponses,
|
||||
GetSsoSettingsData,
|
||||
GetSsoSettingsResponse,
|
||||
GetSsoSettingsResponses,
|
||||
GetStatusData,
|
||||
GetStatusResponse,
|
||||
GetStatusResponses,
|
||||
|
|
@ -258,6 +283,9 @@ export type {
|
|||
UpdateScheduleNotificationsData,
|
||||
UpdateScheduleNotificationsResponse,
|
||||
UpdateScheduleNotificationsResponses,
|
||||
UpdateSsoProviderAutoLinkingData,
|
||||
UpdateSsoProviderAutoLinkingErrors,
|
||||
UpdateSsoProviderAutoLinkingResponses,
|
||||
UpdateVolumeData,
|
||||
UpdateVolumeErrors,
|
||||
UpdateVolumeResponse,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -18,6 +18,7 @@ import { cn } from "~/client/lib/utils";
|
|||
import { APP_VERSION, RCLONE_VERSION, RESTIC_VERSION, SHOUTRRR_VERSION } from "~/client/lib/version";
|
||||
import { useUpdates } from "~/client/hooks/use-updates";
|
||||
import { ReleaseNotesDialog } from "./release-notes-dialog";
|
||||
import { OrganizationSwitcher } from "./organization-switcher";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
const items = [
|
||||
|
|
@ -131,7 +132,8 @@ export function AppSidebar() {
|
|||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="p-4 border-r border-t border-border/50">
|
||||
<SidebarFooter className="p-4 border-r border-border/50">
|
||||
<OrganizationSwitcher />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
|
|||
"bg-[size:40px_40px]",
|
||||
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
||||
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||
"[mask-image:radial-gradient(ellipse_at_top,black_40%,transparent_100%)]",
|
||||
"[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]",
|
||||
)}
|
||||
/>
|
||||
<div className={cn("relative container m-auto z-10", className)}>{children}</div>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function Layout({ loaderData }: Props) {
|
|||
{loaderData.user && (
|
||||
<div className="flex items-center bg-muted/30 border border-border/50 px-2 py-1 rounded-full shadow-sm">
|
||||
<span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5">
|
||||
<span className="text-foreground">{loaderData.user?.username}</span>
|
||||
<span className="text-foreground">{loaderData.user.name}</span>
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
110
app/client/components/organization-switcher.tsx
Normal file
110
app/client/components/organization-switcher.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/client/components/ui/dropdown-menu";
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "~/client/components/ui/sidebar";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useOrganizationContext } from "../hooks/use-org-context";
|
||||
|
||||
function getOrganizationInitials(name?: string): string {
|
||||
const trimmedName = name?.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
return "O";
|
||||
}
|
||||
|
||||
return trimmedName
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function OrganizationSwitcher() {
|
||||
const { isMobile } = useSidebar();
|
||||
const { organizations, activeOrganization } = useOrganizationContext();
|
||||
|
||||
const switchOrganizationMutation = useMutation({
|
||||
mutationFn: async (organizationId: string) => {
|
||||
const { error } = await authClient.organization.setActive({ organizationId });
|
||||
if (error) throw new Error(error.message);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error while switching organizations";
|
||||
toast.error("Failed to switch organization", { description: message });
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (organizations.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu className="mb-3">
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{activeOrganization?.logo ? (
|
||||
<img
|
||||
src={activeOrganization.logo}
|
||||
alt={`${activeOrganization.name} logo`}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-semibold">{getOrganizationInitials(activeOrganization?.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{activeOrganization?.name}</span>
|
||||
<span className="truncate text-xs">{organizations.length} organizations</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">Organizations</DropdownMenuLabel>
|
||||
{organizations.map((organization) => (
|
||||
<DropdownMenuItem
|
||||
key={organization.id}
|
||||
onClick={() => switchOrganizationMutation.mutate(organization.id)}
|
||||
className="gap-2 p-2"
|
||||
disabled={switchOrganizationMutation.isPending}
|
||||
>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md border">
|
||||
{organization.logo ? (
|
||||
<img src={organization.logo} alt={`${organization.name} logo`} className="size-full object-cover" />
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold">{getOrganizationInitials(organization.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate">{organization.name}</span>
|
||||
<DropdownMenuShortcut>{organization.id === activeOrganization?.id && "Current"}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
|
|||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border/80 py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
|
||||
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
217
app/client/components/ui/dropdown-menu.tsx
Normal file
217
app/client/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import * as React from "react";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
6
app/client/functions/get-origin.ts
Normal file
6
app/client/functions/get-origin.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createIsomorphicFn } from "@tanstack/react-start";
|
||||
import { config } from "~/server/core/config";
|
||||
|
||||
export const getOrigin = createIsomorphicFn()
|
||||
.server(() => config.baseUrl)
|
||||
.client(() => window.location.origin);
|
||||
13
app/client/hooks/use-org-context.ts
Normal file
13
app/client/hooks/use-org-context.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useServerFn } from "@tanstack/react-start";
|
||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||
|
||||
export function useOrganizationContext() {
|
||||
const getOrgContext = useServerFn(getOrganizationContext);
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ["organization-context"],
|
||||
queryFn: getOrgContext,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
organizationClient,
|
||||
inferAdditionalFields,
|
||||
} from "better-auth/client/plugins";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { auth } from "~/server/lib/auth";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
|
|
@ -14,6 +15,7 @@ export const authClient = createAuthClient({
|
|||
usernameClient(),
|
||||
adminClient(),
|
||||
organizationClient(),
|
||||
ssoClient(),
|
||||
twoFactorClient(),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
62
app/client/lib/auth-errors.ts
Normal file
62
app/client/lib/auth-errors.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export type LoginErrorCode =
|
||||
| "ACCOUNT_LINK_REQUIRED"
|
||||
| "EMAIL_NOT_VERIFIED"
|
||||
| "INVITE_REQUIRED"
|
||||
| "BANNED_USER"
|
||||
| "SSO_LOGIN_FAILED";
|
||||
|
||||
export function decodeLoginError(error?: string): LoginErrorCode | null {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let decoded = "";
|
||||
|
||||
try {
|
||||
decoded = decodeURIComponent(error);
|
||||
} catch {
|
||||
decoded = error;
|
||||
}
|
||||
|
||||
decoded = decoded.toLowerCase().replace(/[-_\s]+/g, "_");
|
||||
|
||||
if (decoded.includes("account_not_linked")) {
|
||||
return "ACCOUNT_LINK_REQUIRED";
|
||||
}
|
||||
|
||||
if (decoded.includes("email_not_verified")) {
|
||||
return "EMAIL_NOT_VERIFIED";
|
||||
}
|
||||
|
||||
if (decoded.includes("banned_user") || decoded.includes("banned")) {
|
||||
return "BANNED_USER";
|
||||
}
|
||||
|
||||
if (
|
||||
decoded.includes("access_denied") ||
|
||||
decoded.includes("must_be_invited") ||
|
||||
decoded.includes("unable_to_create_session") ||
|
||||
decoded.includes("invite")
|
||||
) {
|
||||
return "INVITE_REQUIRED";
|
||||
}
|
||||
|
||||
return "SSO_LOGIN_FAILED";
|
||||
}
|
||||
|
||||
export function getLoginErrorDescription(errorCode: LoginErrorCode | null): string | null {
|
||||
switch (errorCode) {
|
||||
case "ACCOUNT_LINK_REQUIRED":
|
||||
return "Your account exists but is not linked to this SSO provider. Sign in with username/password first, then enable auto linking in your provider settings or contact your administrator.";
|
||||
case "EMAIL_NOT_VERIFIED":
|
||||
return "Your identity provider did not mark your email as verified.";
|
||||
case "INVITE_REQUIRED":
|
||||
return "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
||||
case "BANNED_USER":
|
||||
return "You have been banned from this application. Please contact support if you believe this is an error.";
|
||||
case "SSO_LOGIN_FAILED":
|
||||
return "SSO authentication failed. Please try again.";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
57
app/client/modules/auth/routes/__tests__/login.test.tsx
Normal file
57
app/client/modules/auth/routes/__tests__/login.test.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
await mock.module("@tanstack/react-router", () => ({
|
||||
useNavigate: () => mock(() => {}),
|
||||
}));
|
||||
|
||||
await mock.module("~/client/api-client/@tanstack/react-query.gen", () => ({
|
||||
getPublicSsoProvidersOptions: () => ({
|
||||
queryKey: ["public-sso-providers"],
|
||||
queryFn: async () => ({ providers: [] }),
|
||||
}),
|
||||
}));
|
||||
|
||||
await mock.module("~/client/lib/auth-client", () => ({
|
||||
authClient: {
|
||||
getSession: mock(async () => ({ data: null })),
|
||||
signIn: {
|
||||
username: mock(async () => ({ data: null, error: null })),
|
||||
sso: mock(async () => ({ data: null, error: null })),
|
||||
},
|
||||
twoFactor: {
|
||||
verifyTotp: mock(async () => ({ data: null, error: null })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { LoginPage } from "../login";
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const inviteOnlyMessage =
|
||||
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
||||
|
||||
describe("LoginPage", () => {
|
||||
test("shows an invite-only message when SSO returns access_denied", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage error="access_denied" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("shows an invite-only message for URL-encoded invitation errors", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage error="must%20be%20invited" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(inviteOnlyMessage)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -10,9 +10,13 @@ import { Input } from "~/client/components/ui/input";
|
|||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/auth-errors";
|
||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { normalizeUsername } from "~/lib/username";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
const loginSchema = type({
|
||||
username: "2<=string<=50",
|
||||
|
|
@ -21,7 +25,11 @@ const loginSchema = type({
|
|||
|
||||
type LoginFormValues = typeof loginSchema.inferIn;
|
||||
|
||||
export function LoginPage() {
|
||||
type LoginPageProps = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function LoginPage({ error }: LoginPageProps = {}) {
|
||||
const navigate = useNavigate();
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
|
|
@ -29,6 +37,12 @@ export function LoginPage() {
|
|||
const [totpCode, setTotpCode] = useState("");
|
||||
const [isVerifying2FA, setIsVerifying2FA] = useState(false);
|
||||
const [trustDevice, setTrustDevice] = useState(false);
|
||||
const errorCode = decodeLoginError(error);
|
||||
const errorDescription = getLoginErrorDescription(errorCode);
|
||||
|
||||
const { data: ssoProviders } = useSuspenseQuery({
|
||||
...getPublicSsoProvidersOptions(),
|
||||
});
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: arktypeResolver(loginSchema),
|
||||
|
|
@ -115,6 +129,27 @@ export function LoginPage() {
|
|||
form.reset();
|
||||
};
|
||||
|
||||
const ssoLoginMutation = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
const callbackPath = "/login";
|
||||
const { data, error } = await authClient.signIn.sso({
|
||||
providerId: providerId,
|
||||
callbackURL: callbackPath,
|
||||
errorCallbackURL: callbackPath,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.url;
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("SSO Login failed", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
if (requires2FA) {
|
||||
return (
|
||||
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
|
||||
|
|
@ -186,6 +221,9 @@ export function LoginPage() {
|
|||
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className={cn("rounded-md border border-destructive/50 p-3 text-sm", { hidden: !errorDescription })}>
|
||||
{errorDescription}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
|
|
@ -227,6 +265,27 @@ export function LoginPage() {
|
|||
</form>
|
||||
</Form>
|
||||
|
||||
{ssoProviders.providers.length > 0 && (
|
||||
<div className="pt-4 border-t border-border/60 space-y-3">
|
||||
<p className="text-sm font-medium">Alternative Sign-in</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{ssoProviders.providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.providerId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
loading={ssoLoginMutation.isPending}
|
||||
disabled={ssoLoginMutation.isPending}
|
||||
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
|
||||
>
|
||||
Log in with {provider.providerId}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
||||
</AuthLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,366 @@
|
|||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Ban, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteSsoInvitationMutation,
|
||||
deleteSsoProviderMutation,
|
||||
getSsoSettingsOptions,
|
||||
updateSsoProviderAutoLinkingMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Switch } from "~/client/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||
import { formatDateWithMonth } from "~/client/lib/datetime";
|
||||
import { getOrigin } from "~/client/functions/get-origin";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
type InvitationRole = "member" | "admin" | "owner";
|
||||
|
||||
export function SsoSettingsSection() {
|
||||
const origin = getOrigin();
|
||||
const navigate = useNavigate();
|
||||
const { activeOrganization } = useOrganizationContext();
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<InvitationRole>("member");
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
...getSsoSettingsOptions(),
|
||||
});
|
||||
|
||||
const providers = data.providers;
|
||||
const invitations = data.invitations;
|
||||
|
||||
const updateProviderAutoLinkingMutation = useMutation({
|
||||
...updateSsoProviderAutoLinkingMutation(),
|
||||
onSuccess: (_, v) => {
|
||||
toast.success(v.body?.enabled ? "Automatic account linking enabled" : "Automatic account linking disabled");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update provider", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteProviderMutation = useMutation({
|
||||
...deleteSsoProviderMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("SSO provider deleted");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete provider", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const inviteMemberMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!activeOrganization) {
|
||||
throw new Error("No active organization found in session");
|
||||
}
|
||||
|
||||
const normalizedEmail = inviteEmail.trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
throw new Error("Email is required");
|
||||
}
|
||||
|
||||
const { error } = await authClient.organization.inviteMember({
|
||||
email: normalizedEmail,
|
||||
role: inviteRole,
|
||||
organizationId: activeOrganization.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Invitation created");
|
||||
setInviteEmail("");
|
||||
setInviteRole("member");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create invitation", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelInvitationMutation = useMutation({
|
||||
mutationFn: async (invitationId: string) => {
|
||||
const { error } = await authClient.organization.cancelInvitation({ invitationId });
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Invitation cancelled");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to cancel invitation", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteInvitationMutation = useMutation({
|
||||
...deleteSsoInvitationMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Invitation deleted");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete invitation", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Registered providers</p>
|
||||
<p className="text-xs text-muted-foreground">Manage identity providers used for organization sign-in.</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!activeOrganization}
|
||||
onClick={() => void navigate({ to: "/settings/sso/new" })}
|
||||
>
|
||||
Register new
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
Only enable automatic account linking for identity providers you trust. You can change this per provider at
|
||||
any time.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Provider ID</TableHead>
|
||||
<TableHead>Domain</TableHead>
|
||||
<TableHead>Issuer</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Auto-link existing account</TableHead>
|
||||
<TableHead>Callback URL</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{providers.map((provider) => (
|
||||
<TableRow key={provider.providerId}>
|
||||
<TableCell className="font-medium">{provider.providerId}</TableCell>
|
||||
<TableCell>{provider.domain}</TableCell>
|
||||
<TableCell className="break-all">{provider.issuer}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase text-xs font-medium px-2 py-0.5 rounded border">{provider.type}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={provider.autoLinkMatchingEmails}
|
||||
disabled={updateProviderAutoLinkingMutation.isPending}
|
||||
onCheckedChange={(enabled) => {
|
||||
updateProviderAutoLinkingMutation.mutate({
|
||||
path: { providerId: provider.providerId },
|
||||
body: { enabled },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.autoLinkMatchingEmails ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="text"
|
||||
readOnly
|
||||
value={`${origin}/api/auth/sso/callback/${provider.providerId}`}
|
||||
className="h-8 max-w-62.5 font-mono text-xs text-muted-foreground"
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete provider"
|
||||
loading={deleteProviderMutation.isPending}
|
||||
disabled={deleteProviderMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete SSO provider</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the SSO provider <strong>{provider.providerId}</strong>?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteProviderMutation.mutate({ path: { providerId: provider.providerId } })}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className={cn({ hidden: providers.length > 0 })}>
|
||||
<TableCell colSpan={7} className="text-center text-sm text-muted-foreground">
|
||||
No providers registered yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border-t border-border/50 pt-6">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">Invite-only access</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Users must be invited or already have an account before they can sign in using SSO.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 @md:grid-cols-[minmax(0,1fr)_180px_auto]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-email">Email</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(event) => setInviteEmail(event.target.value)}
|
||||
placeholder="teammate@example.com"
|
||||
disabled={!activeOrganization || inviteMemberMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-role">Role</Label>
|
||||
<Select
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => setInviteRole(value as InvitationRole)}
|
||||
disabled={!activeOrganization || inviteMemberMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="owner">Owner</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
loading={inviteMemberMutation.isPending}
|
||||
onClick={() => inviteMemberMutation.mutate()}
|
||||
disabled={!activeOrganization}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invitations.map((invitation) => (
|
||||
<TableRow key={invitation.id}>
|
||||
<TableCell className="font-medium">{invitation.email}</TableCell>
|
||||
<TableCell className="uppercase">{invitation.role}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(`text-xs font-medium px-2 py-0.5 rounded border`, {
|
||||
"bg-primary/10 border-primary/20": invitation.status === "pending",
|
||||
"bg-muted border-muted-foreground/20": invitation.status !== "pending",
|
||||
})}
|
||||
>
|
||||
{invitation.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{formatDateWithMonth(invitation.expiresAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Cancel invitation"
|
||||
loading={cancelInvitationMutation.isPending}
|
||||
disabled={cancelInvitationMutation.isPending}
|
||||
onClick={() => cancelInvitationMutation.mutate(invitation.id)}
|
||||
className={cn({ hidden: invitation.status !== "pending" })}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete invitation"
|
||||
loading={deleteInvitationMutation.isPending}
|
||||
disabled={deleteInvitationMutation.isPending}
|
||||
onClick={() => deleteInvitationMutation.mutate({ path: { invitationId: invitation.id } })}
|
||||
className={cn({ hidden: invitation.status === "pending" })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className={cn({ hidden: invitations.length > 0 })}>
|
||||
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground">
|
||||
No invitations yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Shield, ShieldAlert, UserMinus, UserCheck, Trash2, Search, AlertTriangle } from "lucide-react";
|
||||
import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Shield, ShieldAlert, UserCheck, Trash2, Search, AlertTriangle, Ban, KeyRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
|
|
@ -17,29 +17,28 @@ import {
|
|||
DialogTitle,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { CreateUserDialog } from "./create-user-dialog";
|
||||
import { getUserDeletionImpactOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function UserManagement() {
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUser = session?.user;
|
||||
import {
|
||||
getAdminUsersOptions,
|
||||
getUserDeletionImpactOptions,
|
||||
deleteUserAccountMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function UserManagement({ currentUser }: { currentUser: { id: string } | undefined | null }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
||||
const [userToBan, setUserToBan] = useState<{ id: string; name: string; isBanned: boolean } | null>(null);
|
||||
const [userToManageAccounts, setUserToManageAccounts] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
accounts: { id: string; providerId: string }[];
|
||||
}>();
|
||||
|
||||
const { data: deletionImpact, isLoading: isLoadingImpact } = useQuery({
|
||||
...getUserDeletionImpactOptions({ path: { userId: userToDelete ?? "" } }),
|
||||
enabled: Boolean(userToDelete),
|
||||
});
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ["admin-users"],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.admin.listUsers({ query: { limit: 100 } });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
});
|
||||
const { data } = useSuspenseQuery({ ...getAdminUsersOptions() });
|
||||
|
||||
const setRoleMutation = useMutation({
|
||||
mutationFn: async ({ userId, role }: { userId: string; role: "user" | "admin" }) => {
|
||||
|
|
@ -48,10 +47,9 @@ export function UserManagement() {
|
|||
},
|
||||
onSuccess: () => {
|
||||
toast.success("User role updated successfully");
|
||||
void refetch();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error("Failed to update role", { description: err.message });
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update role", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -62,36 +60,53 @@ export function UserManagement() {
|
|||
},
|
||||
onSuccess: () => {
|
||||
toast.success("User ban status updated successfully");
|
||||
void refetch();
|
||||
},
|
||||
onMutate: () => {
|
||||
setUserToBan(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error("Failed to update ban status", { description: err.message });
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update ban status", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const filteredUsers = data?.users.filter(
|
||||
(user) =>
|
||||
user.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(user as any).username?.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!userToDelete) return;
|
||||
|
||||
try {
|
||||
const { error } = await authClient.admin.removeUser({ userId: userToDelete });
|
||||
const deleteUser = useMutation({
|
||||
mutationFn: async (userId: string) => {
|
||||
const { error } = await authClient.admin.removeUser({ userId });
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("User deleted successfully");
|
||||
setUserToDelete(null);
|
||||
void refetch();
|
||||
} catch (err: any) {
|
||||
toast.error("Failed to delete user", { description: err.message });
|
||||
}
|
||||
};
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete user", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAccount = useMutation({
|
||||
...deleteUserAccountMutation(),
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("Account removed successfully");
|
||||
setUserToManageAccounts((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
accounts: prev.accounts.filter((a) => a.id !== variables.path.accountId),
|
||||
};
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to remove account", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const normalizedSearch = search.toLowerCase();
|
||||
const filteredUsers = data.users.filter((user) => {
|
||||
const name = user.name?.toLowerCase() ?? "";
|
||||
const email = user.email.toLowerCase();
|
||||
|
||||
return name.includes(normalizedSearch) || email.includes(normalizedSearch);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
|
|
@ -105,7 +120,7 @@ export function UserManagement() {
|
|||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<CreateUserDialog onUserCreated={() => void refetch()} />
|
||||
<CreateUserDialog />
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
|
|
@ -119,21 +134,16 @@ export function UserManagement() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className={cn({ hidden: !isLoading })}>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
Loading users...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={cn({ hidden: isLoading || (filteredUsers && filteredUsers.length > 0) })}>
|
||||
<TableRow className={cn({ hidden: filteredUsers.length > 0 })}>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
No users found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{filteredUsers?.map((user) => (
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="font-medium">{user.name ?? user.email}</span>
|
||||
<span className="text-sm text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
@ -149,12 +159,12 @@ export function UserManagement() {
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className={cn("flex justify-end gap-2", { hidden: user.id === currentUser?.id })}>
|
||||
<div className={cn("flex justify-end gap-2")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Demote to User"
|
||||
className={cn({ hidden: user.role !== "admin" })}
|
||||
className={cn({ hidden: user.role !== "admin" || user.id === currentUser?.id })}
|
||||
onClick={() => setRoleMutation.mutate({ userId: user.id, role: "user" })}
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
|
|
@ -163,7 +173,7 @@ export function UserManagement() {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
title="Promote to Admin"
|
||||
className={cn({ hidden: user.role === "admin" })}
|
||||
className={cn({ hidden: user.role === "admin" || user.id === currentUser?.id })}
|
||||
onClick={() => setRoleMutation.mutate({ userId: user.id, role: "admin" })}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
|
|
@ -172,25 +182,46 @@ export function UserManagement() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Unban User"
|
||||
className={cn({ hidden: !user.banned })}
|
||||
onClick={() => setUserToBan({ id: user.id, name: user.name, isBanned: true })}
|
||||
title="Unban user"
|
||||
className={cn({ hidden: !user.banned || user.id === currentUser?.id })}
|
||||
onClick={() => setUserToBan({ id: user.id, name: user.name ?? user.email, isBanned: true })}
|
||||
>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Ban User"
|
||||
className={cn({ hidden: !!user.banned })}
|
||||
onClick={() => setUserToBan({ id: user.id, name: user.name, isBanned: false })}
|
||||
title="Ban user"
|
||||
className={cn({ hidden: !!user.banned || user.id === currentUser?.id })}
|
||||
onClick={() => setUserToBan({ id: user.id, name: user.name ?? user.email, isBanned: false })}
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" title="Delete User" onClick={() => setUserToDelete(user.id)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete User"
|
||||
className={cn({ hidden: user.id === currentUser?.id })}
|
||||
onClick={() => setUserToDelete(user.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Manage Accounts"
|
||||
onClick={() =>
|
||||
setUserToManageAccounts({
|
||||
id: user.id,
|
||||
name: user.name ?? user.email,
|
||||
accounts: user.accounts,
|
||||
})
|
||||
}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -242,7 +273,11 @@ export function UserManagement() {
|
|||
<Button variant="outline" onClick={() => setUserToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={isLoadingImpact} onClick={handleDeleteUser}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isLoadingImpact || deleteUser.isPending}
|
||||
onClick={() => deleteUser.mutate(userToDelete!)}
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
@ -267,20 +302,60 @@ export function UserManagement() {
|
|||
<Button
|
||||
variant="default"
|
||||
className={cn({ hidden: !userToBan?.isBanned })}
|
||||
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan!.id, ban: false })}
|
||||
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan?.id ?? "", ban: false })}
|
||||
>
|
||||
Unban User
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className={cn({ hidden: !!userToBan?.isBanned })}
|
||||
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan!.id, ban: true })}
|
||||
className={cn({ hidden: Boolean(userToBan?.isBanned) })}
|
||||
onClick={() => toggleBanUserMutation.mutate({ userId: userToBan?.id ?? "", ban: true })}
|
||||
>
|
||||
Ban User
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(userToManageAccounts)} onOpenChange={(open) => !open && setUserToManageAccounts(undefined)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Accounts</DialogTitle>
|
||||
<DialogDescription>Linked authentication accounts for {userToManageAccounts?.name}.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={cn("text-sm text-muted-foreground text-center py-4", {
|
||||
hidden: userToManageAccounts?.accounts.length,
|
||||
})}
|
||||
>
|
||||
No accounts linked.
|
||||
</p>
|
||||
{userToManageAccounts?.accounts.map((account) => (
|
||||
<div key={account.id} className="flex items-center justify-between p-3 border rounded-md">
|
||||
<span className="text-sm font-medium">{account.providerId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Remove account"
|
||||
disabled={deleteAccount.isPending}
|
||||
onClick={() =>
|
||||
deleteAccount.mutate({ path: { userId: userToManageAccounts.id, accountId: account.id } })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUserToManageAccounts(undefined)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
258
app/client/modules/settings/routes/create-sso-provider.tsx
Normal file
258
app/client/modules/settings/routes/create-sso-provider.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { type } from "arktype";
|
||||
import { ShieldCheck, Plus } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Switch } from "~/client/components/ui/switch";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||
|
||||
const ssoProviderSchema = type({
|
||||
providerId: "string>=1",
|
||||
issuer: "string>=1",
|
||||
domain: "string>=1",
|
||||
clientId: "string>=1",
|
||||
clientSecret: "string>=1",
|
||||
discoveryEndpoint: "string>=1",
|
||||
linkMatchingEmails: "boolean",
|
||||
});
|
||||
|
||||
type ProviderForm = typeof ssoProviderSchema.infer;
|
||||
|
||||
export function CreateSsoProviderPage() {
|
||||
const navigate = useNavigate();
|
||||
const formId = useId();
|
||||
const { activeOrganization } = useOrganizationContext();
|
||||
|
||||
const form = useForm<ProviderForm>({
|
||||
resolver: arktypeResolver(ssoProviderSchema),
|
||||
defaultValues: {
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
domain: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
discoveryEndpoint: "",
|
||||
linkMatchingEmails: false,
|
||||
},
|
||||
});
|
||||
|
||||
const updateProviderAutoLinking = useMutation({
|
||||
...updateSsoProviderAutoLinkingMutation(),
|
||||
});
|
||||
|
||||
const registerProvider = useMutation({
|
||||
mutationFn: async (formData: ProviderForm) => {
|
||||
const { error } = await authClient.sso.register({
|
||||
providerId: formData.providerId,
|
||||
issuer: formData.issuer,
|
||||
domain: formData.domain,
|
||||
organizationId: activeOrganization.id,
|
||||
oidcConfig: {
|
||||
clientId: formData.clientId,
|
||||
clientSecret: formData.clientSecret,
|
||||
discoveryEndpoint: formData.discoveryEndpoint,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
await updateProviderAutoLinking
|
||||
.mutateAsync({
|
||||
path: { providerId: formData.providerId },
|
||||
body: { enabled: formData.linkMatchingEmails },
|
||||
})
|
||||
.catch((updateError) => {
|
||||
toast.warning("Auto-link setting could not be saved", {
|
||||
description: parseError(updateError)?.message,
|
||||
});
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("SSO provider registered successfully");
|
||||
void navigate({ to: "/settings", search: { tab: "users" } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to register provider", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<ShieldCheck className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Register SSO Provider</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit((values) => registerProvider.mutate(values))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid gap-4 @xl:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="acme-oidc" disabled={registerProvider.isPending} />
|
||||
</FormControl>
|
||||
<FormDescription>Unique identifier used in callback URLs.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="example.com" disabled={registerProvider.isPending} />
|
||||
</FormControl>
|
||||
<FormDescription>Used to discover providers during login.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issuer URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://idp.example.com" disabled={registerProvider.isPending} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="discoveryEndpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discovery Endpoint</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://idp.example.com/.well-known/openid-configuration"
|
||||
disabled={registerProvider.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="oidc-client-id" disabled={registerProvider.isPending} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="oidc-client-secret"
|
||||
disabled={registerProvider.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="linkMatchingEmails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="rounded-md border p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Link matching emails to existing accounts</FormLabel>
|
||||
<FormDescription>
|
||||
If enabled, users who sign in with this provider will automatically access their existing
|
||||
account when the email address matches.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={registerProvider.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => void navigate({ to: "/settings", search: { tab: "users" } })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} loading={registerProvider.isPending}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Register Provider
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import { type AppContext } from "~/context";
|
|||
import { TwoFactorSection } from "../components/two-factor-section";
|
||||
import { UserManagement } from "../components/user-management";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { SsoSettingsSection } from "../components/sso/sso-settings-section";
|
||||
|
||||
type Props = {
|
||||
appContext: AppContext;
|
||||
|
|
@ -165,7 +166,7 @@ export function SettingsPage({ appContext }: Props) {
|
|||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
{isAdmin && <TabsTrigger value="users">Users</TabsTrigger>}
|
||||
{isAdmin && <TabsTrigger value="users">Users & Authentication</TabsTrigger>}
|
||||
{isAdmin && <TabsTrigger value="system">System</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -307,7 +308,7 @@ export function SettingsPage({ appContext }: Props) {
|
|||
</TabsContent>
|
||||
|
||||
{isAdmin && (
|
||||
<TabsContent value="users" className="mt-0">
|
||||
<TabsContent value="users" className="mt-0 space-y-4">
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="border-b border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
|
@ -316,7 +317,20 @@ export function SettingsPage({ appContext }: Props) {
|
|||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Manage users, roles and permissions</CardDescription>
|
||||
</div>
|
||||
<UserManagement />
|
||||
<UserManagement currentUser={appContext.user} />
|
||||
</Card>
|
||||
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="border-b border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<SettingsIcon className="size-5" />
|
||||
Single Sign-On
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<SsoSettingsSection />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ type User = {
|
|||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
name: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
twoFactorEnabled?: boolean | null;
|
||||
role?: string | null | undefined;
|
||||
|
|
|
|||
15
app/drizzle/20260227200731_sad_luke_cage/migration.sql
Normal file
15
app/drizzle/20260227200731_sad_luke_cage/migration.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
CREATE TABLE `sso_provider` (
|
||||
`id` text PRIMARY KEY,
|
||||
`provider_id` text NOT NULL UNIQUE,
|
||||
`organization_id` text NOT NULL,
|
||||
`user_id` text,
|
||||
`issuer` text NOT NULL,
|
||||
`domain` text NOT NULL,
|
||||
`auto_link_matching_emails` integer DEFAULT false NOT NULL,
|
||||
`oidc_config` text,
|
||||
`saml_config` text,
|
||||
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||
CONSTRAINT `fk_sso_provider_organization_id_organization_id_fk` FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_sso_provider_user_id_users_table_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON DELETE SET NULL
|
||||
);
|
||||
2185
app/drizzle/20260227200731_sad_luke_cage/snapshot.json
Normal file
2185
app/drizzle/20260227200731_sad_luke_cage/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,13 +4,20 @@ import { auth } from "~/server/lib/auth";
|
|||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { authService } from "~/server/modules/auth/auth.service";
|
||||
|
||||
function isAuthRoute(pathname: string): boolean {
|
||||
if (pathname === "/onboarding") return true;
|
||||
if (pathname === "/login") return true;
|
||||
if (pathname.match(/^\/login\/[^/]+$/)) return true;
|
||||
if (pathname.match(/^\/login\/[^/]+\/error$/)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
|
||||
const headers = getRequestHeaders();
|
||||
const session = await auth.api.getSession({ headers });
|
||||
const pathname = new URL(request.url).pathname;
|
||||
|
||||
const isAuthRoute = ["/login", "/onboarding"].includes(new URL(request.url).pathname);
|
||||
|
||||
if (!session?.user?.id && !isAuthRoute) {
|
||||
if (!session?.user?.id && !isAuthRoute(pathname)) {
|
||||
const hasUsers = await authService.hasUsers();
|
||||
if (!hasUsers) {
|
||||
throw redirect({ to: "/onboarding" });
|
||||
|
|
@ -20,8 +27,12 @@ export const authMiddleware = createMiddleware().server(async ({ next, request }
|
|||
}
|
||||
|
||||
if (session?.user?.id) {
|
||||
if (isAuthRoute) {
|
||||
throw redirect({ to: "/" });
|
||||
if (!session.user.hasDownloadedResticPassword && pathname !== "/download-recovery-key") {
|
||||
throw redirect({ to: "/download-recovery-key" });
|
||||
}
|
||||
|
||||
if (isAuthRoute(pathname)) {
|
||||
throw redirect({ to: "/volumes" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as dashboardRouteRouteImport } from './routes/(dashboard)/route'
|
||||
import { Route as authRouteRouteImport } from './routes/(auth)/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ApiSplatRouteImport } from './routes/api.$'
|
||||
import { Route as authOnboardingRouteImport } from './routes/(auth)/onboarding'
|
||||
|
|
@ -26,8 +27,10 @@ import { Route as dashboardRepositoriesCreateRouteImport } from './routes/(dashb
|
|||
import { Route as dashboardNotificationsCreateRouteImport } from './routes/(dashboard)/notifications/create'
|
||||
import { Route as dashboardNotificationsNotificationIdRouteImport } from './routes/(dashboard)/notifications/$notificationId'
|
||||
import { Route as dashboardBackupsCreateRouteImport } from './routes/(dashboard)/backups/create'
|
||||
import { Route as authLoginErrorRouteImport } from './routes/(auth)/login.error'
|
||||
import { Route as dashboardRepositoriesRepositoryIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/index'
|
||||
import { Route as dashboardBackupsBackupIdIndexRouteImport } from './routes/(dashboard)/backups/$backupId/index'
|
||||
import { Route as dashboardSettingsSsoNewRouteImport } from './routes/(dashboard)/settings/sso/new'
|
||||
import { Route as dashboardRepositoriesRepositoryIdEditRouteImport } from './routes/(dashboard)/repositories/$repositoryId/edit'
|
||||
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/index'
|
||||
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore'
|
||||
|
|
@ -37,6 +40,10 @@ const dashboardRouteRoute = dashboardRouteRouteImport.update({
|
|||
id: '/(dashboard)',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authRouteRoute = authRouteRouteImport.update({
|
||||
id: '/(auth)',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
|
|
@ -48,19 +55,19 @@ const ApiSplatRoute = ApiSplatRouteImport.update({
|
|||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authOnboardingRoute = authOnboardingRouteImport.update({
|
||||
id: '/(auth)/onboarding',
|
||||
id: '/onboarding',
|
||||
path: '/onboarding',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
getParentRoute: () => authRouteRoute,
|
||||
} as any)
|
||||
const authLoginRoute = authLoginRouteImport.update({
|
||||
id: '/(auth)/login',
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
getParentRoute: () => authRouteRoute,
|
||||
} as any)
|
||||
const authDownloadRecoveryKeyRoute = authDownloadRecoveryKeyRouteImport.update({
|
||||
id: '/(auth)/download-recovery-key',
|
||||
id: '/download-recovery-key',
|
||||
path: '/download-recovery-key',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
getParentRoute: () => authRouteRoute,
|
||||
} as any)
|
||||
const dashboardVolumesIndexRoute = dashboardVolumesIndexRouteImport.update({
|
||||
id: '/volumes/',
|
||||
|
|
@ -123,6 +130,11 @@ const dashboardBackupsCreateRoute = dashboardBackupsCreateRouteImport.update({
|
|||
path: '/backups/create',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const authLoginErrorRoute = authLoginErrorRouteImport.update({
|
||||
id: '/error',
|
||||
path: '/error',
|
||||
getParentRoute: () => authLoginRoute,
|
||||
} as any)
|
||||
const dashboardRepositoriesRepositoryIdIndexRoute =
|
||||
dashboardRepositoriesRepositoryIdIndexRouteImport.update({
|
||||
id: '/repositories/$repositoryId/',
|
||||
|
|
@ -135,6 +147,11 @@ const dashboardBackupsBackupIdIndexRoute =
|
|||
path: '/backups/$backupId/',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardSettingsSsoNewRoute = dashboardSettingsSsoNewRouteImport.update({
|
||||
id: '/settings/sso/new',
|
||||
path: '/settings/sso/new',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardRepositoriesRepositoryIdEditRoute =
|
||||
dashboardRepositoriesRepositoryIdEditRouteImport.update({
|
||||
id: '/repositories/$repositoryId/edit',
|
||||
|
|
@ -163,9 +180,10 @@ const dashboardBackupsBackupIdSnapshotIdRestoreRoute =
|
|||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/download-recovery-key': typeof authDownloadRecoveryKeyRoute
|
||||
'/login': typeof authLoginRoute
|
||||
'/login': typeof authLoginRouteWithChildren
|
||||
'/onboarding': typeof authOnboardingRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/login/error': typeof authLoginErrorRoute
|
||||
'/backups/create': typeof dashboardBackupsCreateRoute
|
||||
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
||||
'/notifications/create': typeof dashboardNotificationsCreateRoute
|
||||
|
|
@ -178,6 +196,7 @@ export interface FileRoutesByFullPath {
|
|||
'/settings/': typeof dashboardSettingsIndexRoute
|
||||
'/volumes/': typeof dashboardVolumesIndexRoute
|
||||
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
|
||||
'/settings/sso/new': typeof dashboardSettingsSsoNewRoute
|
||||
'/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
|
||||
'/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
|
||||
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||
|
|
@ -187,9 +206,10 @@ export interface FileRoutesByFullPath {
|
|||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/download-recovery-key': typeof authDownloadRecoveryKeyRoute
|
||||
'/login': typeof authLoginRoute
|
||||
'/login': typeof authLoginRouteWithChildren
|
||||
'/onboarding': typeof authOnboardingRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/login/error': typeof authLoginErrorRoute
|
||||
'/backups/create': typeof dashboardBackupsCreateRoute
|
||||
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
||||
'/notifications/create': typeof dashboardNotificationsCreateRoute
|
||||
|
|
@ -202,6 +222,7 @@ export interface FileRoutesByTo {
|
|||
'/settings': typeof dashboardSettingsIndexRoute
|
||||
'/volumes': typeof dashboardVolumesIndexRoute
|
||||
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
|
||||
'/settings/sso/new': typeof dashboardSettingsSsoNewRoute
|
||||
'/backups/$backupId': typeof dashboardBackupsBackupIdIndexRoute
|
||||
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdIndexRoute
|
||||
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||
|
|
@ -211,11 +232,13 @@ export interface FileRoutesByTo {
|
|||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/(auth)': typeof authRouteRouteWithChildren
|
||||
'/(dashboard)': typeof dashboardRouteRouteWithChildren
|
||||
'/(auth)/download-recovery-key': typeof authDownloadRecoveryKeyRoute
|
||||
'/(auth)/login': typeof authLoginRoute
|
||||
'/(auth)/login': typeof authLoginRouteWithChildren
|
||||
'/(auth)/onboarding': typeof authOnboardingRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/(auth)/login/error': typeof authLoginErrorRoute
|
||||
'/(dashboard)/backups/create': typeof dashboardBackupsCreateRoute
|
||||
'/(dashboard)/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
||||
'/(dashboard)/notifications/create': typeof dashboardNotificationsCreateRoute
|
||||
|
|
@ -228,6 +251,7 @@ export interface FileRoutesById {
|
|||
'/(dashboard)/settings/': typeof dashboardSettingsIndexRoute
|
||||
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
|
||||
'/(dashboard)/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
|
||||
'/(dashboard)/settings/sso/new': typeof dashboardSettingsSsoNewRoute
|
||||
'/(dashboard)/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
|
||||
'/(dashboard)/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
|
||||
'/(dashboard)/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||
|
|
@ -242,6 +266,7 @@ export interface FileRouteTypes {
|
|||
| '/login'
|
||||
| '/onboarding'
|
||||
| '/api/$'
|
||||
| '/login/error'
|
||||
| '/backups/create'
|
||||
| '/notifications/$notificationId'
|
||||
| '/notifications/create'
|
||||
|
|
@ -254,6 +279,7 @@ export interface FileRouteTypes {
|
|||
| '/settings/'
|
||||
| '/volumes/'
|
||||
| '/repositories/$repositoryId/edit'
|
||||
| '/settings/sso/new'
|
||||
| '/backups/$backupId/'
|
||||
| '/repositories/$repositoryId/'
|
||||
| '/backups/$backupId/$snapshotId/restore'
|
||||
|
|
@ -266,6 +292,7 @@ export interface FileRouteTypes {
|
|||
| '/login'
|
||||
| '/onboarding'
|
||||
| '/api/$'
|
||||
| '/login/error'
|
||||
| '/backups/create'
|
||||
| '/notifications/$notificationId'
|
||||
| '/notifications/create'
|
||||
|
|
@ -278,6 +305,7 @@ export interface FileRouteTypes {
|
|||
| '/settings'
|
||||
| '/volumes'
|
||||
| '/repositories/$repositoryId/edit'
|
||||
| '/settings/sso/new'
|
||||
| '/backups/$backupId'
|
||||
| '/repositories/$repositoryId'
|
||||
| '/backups/$backupId/$snapshotId/restore'
|
||||
|
|
@ -286,11 +314,13 @@ export interface FileRouteTypes {
|
|||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/(auth)'
|
||||
| '/(dashboard)'
|
||||
| '/(auth)/download-recovery-key'
|
||||
| '/(auth)/login'
|
||||
| '/(auth)/onboarding'
|
||||
| '/api/$'
|
||||
| '/(auth)/login/error'
|
||||
| '/(dashboard)/backups/create'
|
||||
| '/(dashboard)/notifications/$notificationId'
|
||||
| '/(dashboard)/notifications/create'
|
||||
|
|
@ -303,6 +333,7 @@ export interface FileRouteTypes {
|
|||
| '/(dashboard)/settings/'
|
||||
| '/(dashboard)/volumes/'
|
||||
| '/(dashboard)/repositories/$repositoryId/edit'
|
||||
| '/(dashboard)/settings/sso/new'
|
||||
| '/(dashboard)/backups/$backupId/'
|
||||
| '/(dashboard)/repositories/$repositoryId/'
|
||||
| '/(dashboard)/backups/$backupId/$snapshotId/restore'
|
||||
|
|
@ -312,10 +343,8 @@ export interface FileRouteTypes {
|
|||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
authRouteRoute: typeof authRouteRouteWithChildren
|
||||
dashboardRouteRoute: typeof dashboardRouteRouteWithChildren
|
||||
authDownloadRecoveryKeyRoute: typeof authDownloadRecoveryKeyRoute
|
||||
authLoginRoute: typeof authLoginRoute
|
||||
authOnboardingRoute: typeof authOnboardingRoute
|
||||
ApiSplatRoute: typeof ApiSplatRoute
|
||||
}
|
||||
|
||||
|
|
@ -328,6 +357,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof dashboardRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)': {
|
||||
id: '/(auth)'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
preLoaderRoute: typeof authRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
|
|
@ -347,21 +383,21 @@ declare module '@tanstack/react-router' {
|
|||
path: '/onboarding'
|
||||
fullPath: '/onboarding'
|
||||
preLoaderRoute: typeof authOnboardingRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
parentRoute: typeof authRouteRoute
|
||||
}
|
||||
'/(auth)/login': {
|
||||
id: '/(auth)/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof authLoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
parentRoute: typeof authRouteRoute
|
||||
}
|
||||
'/(auth)/download-recovery-key': {
|
||||
id: '/(auth)/download-recovery-key'
|
||||
path: '/download-recovery-key'
|
||||
fullPath: '/download-recovery-key'
|
||||
preLoaderRoute: typeof authDownloadRecoveryKeyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
parentRoute: typeof authRouteRoute
|
||||
}
|
||||
'/(dashboard)/volumes/': {
|
||||
id: '/(dashboard)/volumes/'
|
||||
|
|
@ -440,6 +476,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof dashboardBackupsCreateRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(auth)/login/error': {
|
||||
id: '/(auth)/login/error'
|
||||
path: '/error'
|
||||
fullPath: '/login/error'
|
||||
preLoaderRoute: typeof authLoginErrorRouteImport
|
||||
parentRoute: typeof authLoginRoute
|
||||
}
|
||||
'/(dashboard)/repositories/$repositoryId/': {
|
||||
id: '/(dashboard)/repositories/$repositoryId/'
|
||||
path: '/repositories/$repositoryId'
|
||||
|
|
@ -454,6 +497,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof dashboardBackupsBackupIdIndexRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/settings/sso/new': {
|
||||
id: '/(dashboard)/settings/sso/new'
|
||||
path: '/settings/sso/new'
|
||||
fullPath: '/settings/sso/new'
|
||||
preLoaderRoute: typeof dashboardSettingsSsoNewRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/repositories/$repositoryId/edit': {
|
||||
id: '/(dashboard)/repositories/$repositoryId/edit'
|
||||
path: '/repositories/$repositoryId/edit'
|
||||
|
|
@ -485,6 +535,34 @@ declare module '@tanstack/react-router' {
|
|||
}
|
||||
}
|
||||
|
||||
interface authLoginRouteChildren {
|
||||
authLoginErrorRoute: typeof authLoginErrorRoute
|
||||
}
|
||||
|
||||
const authLoginRouteChildren: authLoginRouteChildren = {
|
||||
authLoginErrorRoute: authLoginErrorRoute,
|
||||
}
|
||||
|
||||
const authLoginRouteWithChildren = authLoginRoute._addFileChildren(
|
||||
authLoginRouteChildren,
|
||||
)
|
||||
|
||||
interface authRouteRouteChildren {
|
||||
authDownloadRecoveryKeyRoute: typeof authDownloadRecoveryKeyRoute
|
||||
authLoginRoute: typeof authLoginRouteWithChildren
|
||||
authOnboardingRoute: typeof authOnboardingRoute
|
||||
}
|
||||
|
||||
const authRouteRouteChildren: authRouteRouteChildren = {
|
||||
authDownloadRecoveryKeyRoute: authDownloadRecoveryKeyRoute,
|
||||
authLoginRoute: authLoginRouteWithChildren,
|
||||
authOnboardingRoute: authOnboardingRoute,
|
||||
}
|
||||
|
||||
const authRouteRouteWithChildren = authRouteRoute._addFileChildren(
|
||||
authRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface dashboardRouteRouteChildren {
|
||||
dashboardBackupsCreateRoute: typeof dashboardBackupsCreateRoute
|
||||
dashboardNotificationsNotificationIdRoute: typeof dashboardNotificationsNotificationIdRoute
|
||||
|
|
@ -498,6 +576,7 @@ interface dashboardRouteRouteChildren {
|
|||
dashboardSettingsIndexRoute: typeof dashboardSettingsIndexRoute
|
||||
dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute
|
||||
dashboardRepositoriesRepositoryIdEditRoute: typeof dashboardRepositoriesRepositoryIdEditRoute
|
||||
dashboardSettingsSsoNewRoute: typeof dashboardSettingsSsoNewRoute
|
||||
dashboardBackupsBackupIdIndexRoute: typeof dashboardBackupsBackupIdIndexRoute
|
||||
dashboardRepositoriesRepositoryIdIndexRoute: typeof dashboardRepositoriesRepositoryIdIndexRoute
|
||||
dashboardBackupsBackupIdSnapshotIdRestoreRoute: typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||
|
|
@ -520,6 +599,7 @@ const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
|
|||
dashboardVolumesIndexRoute: dashboardVolumesIndexRoute,
|
||||
dashboardRepositoriesRepositoryIdEditRoute:
|
||||
dashboardRepositoriesRepositoryIdEditRoute,
|
||||
dashboardSettingsSsoNewRoute: dashboardSettingsSsoNewRoute,
|
||||
dashboardBackupsBackupIdIndexRoute: dashboardBackupsBackupIdIndexRoute,
|
||||
dashboardRepositoriesRepositoryIdIndexRoute:
|
||||
dashboardRepositoriesRepositoryIdIndexRoute,
|
||||
|
|
@ -537,10 +617,8 @@ const dashboardRouteRouteWithChildren = dashboardRouteRoute._addFileChildren(
|
|||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
authRouteRoute: authRouteRouteWithChildren,
|
||||
dashboardRouteRoute: dashboardRouteRouteWithChildren,
|
||||
authDownloadRecoveryKeyRoute: authDownloadRecoveryKeyRoute,
|
||||
authLoginRoute: authLoginRoute,
|
||||
authOnboardingRoute: authOnboardingRoute,
|
||||
ApiSplatRoute: ApiSplatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
|
|
|||
23
app/routes/(auth)/login.error.tsx
Normal file
23
app/routes/(auth)/login.error.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { type } from "arktype";
|
||||
import { LoginPage } from "~/client/modules/auth/routes/login";
|
||||
|
||||
export const Route = createFileRoute("/(auth)/login/error")({
|
||||
component: RouteComponent,
|
||||
validateSearch: type({ error: "string?" }),
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Login Error" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Resolve SSO sign-in errors.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { error } = Route.useSearch();
|
||||
|
||||
return <LoginPage error={error} />;
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router";
|
||||
import { type } from "arktype";
|
||||
import { LoginPage } from "~/client/modules/auth/routes/login";
|
||||
|
||||
export const Route = createFileRoute("/(auth)/login")({
|
||||
component: LoginPage,
|
||||
component: RouteComponent,
|
||||
validateSearch: type({ error: "string?" }),
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Login" },
|
||||
|
|
@ -13,3 +15,14 @@ export const Route = createFileRoute("/(auth)/login")({
|
|||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { error } = Route.useSearch();
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
|
||||
if (pathname !== "/login") {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
return <LoginPage error={error} />;
|
||||
}
|
||||
|
|
|
|||
9
app/routes/(auth)/route.tsx
Normal file
9
app/routes/(auth)/route.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
|
||||
export const Route = createFileRoute("/(auth)")({
|
||||
component: () => <Outlet />,
|
||||
server: {
|
||||
middleware: [authMiddleware],
|
||||
},
|
||||
});
|
||||
|
|
@ -3,12 +3,21 @@ import { fetchUser } from "../route";
|
|||
import type { AppContext } from "~/context";
|
||||
import { SettingsPage } from "~/client/modules/settings/routes/settings";
|
||||
import { type } from "arktype";
|
||||
import { getAdminUsersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/settings/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: type({ tab: "string?" }),
|
||||
loader: async () => {
|
||||
loader: async ({ context }) => {
|
||||
const authContext = await fetchUser();
|
||||
|
||||
if (authContext.user?.role === "admin") {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureQueryData(getSsoSettingsOptions()),
|
||||
context.queryClient.ensureQueryData(getAdminUsersOptions()),
|
||||
]);
|
||||
}
|
||||
|
||||
return authContext as AppContext;
|
||||
},
|
||||
staticData: {
|
||||
|
|
|
|||
32
app/routes/(dashboard)/settings/sso/new.tsx
Normal file
32
app/routes/(dashboard)/settings/sso/new.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { CreateSsoProviderPage } from "~/client/modules/settings/routes/create-sso-provider";
|
||||
import { fetchUser } from "../../route";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/settings/sso/new")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
const authContext = await fetchUser();
|
||||
|
||||
if (authContext.user?.role !== "admin") {
|
||||
throw redirect({ to: "/settings" });
|
||||
}
|
||||
|
||||
return authContext;
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: () => [{ label: "Settings", href: "/settings" }, { label: "Register SSO Provider" }],
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Register SSO Provider" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Register a new OIDC identity provider for organization sign-in.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <CreateSsoProviderPage />;
|
||||
}
|
||||
|
|
@ -8,11 +8,7 @@ const handle = ({ request }: { request: Request }) => app.fetch(request.clone())
|
|||
export const Route = createFileRoute("/api/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: handle,
|
||||
POST: handle,
|
||||
DELETE: handle,
|
||||
PUT: handle,
|
||||
PATCH: handle,
|
||||
ANY: handle,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const relations = defineRelations(schema, (r) => ({
|
|||
sessions: r.many.sessionsTable(),
|
||||
members: r.many.member(),
|
||||
twoFactors: r.many.twoFactor(),
|
||||
ssoProviders: r.many.ssoProvider(),
|
||||
organizations: r.many.organization({
|
||||
from: r.usersTable.id.through(r.member.userId),
|
||||
to: r.organization.id.through(r.member.organizationId),
|
||||
|
|
@ -104,6 +105,19 @@ export const relations = defineRelations(schema, (r) => ({
|
|||
volumes: r.many.volumesTable(),
|
||||
members: r.many.member(),
|
||||
invitations: r.many.invitation(),
|
||||
ssoProviders: r.many.ssoProvider(),
|
||||
},
|
||||
ssoProvider: {
|
||||
user: r.one.usersTable({
|
||||
from: r.ssoProvider.userId,
|
||||
to: r.usersTable.id,
|
||||
optional: true,
|
||||
}),
|
||||
organization: r.one.organization({
|
||||
from: r.ssoProvider.organizationId,
|
||||
to: r.organization.id,
|
||||
optional: false,
|
||||
}),
|
||||
},
|
||||
volumesTable: {
|
||||
backupSchedules: r.many.backupSchedulesTable(),
|
||||
|
|
|
|||
|
|
@ -173,6 +173,27 @@ export const invitation = sqliteTable(
|
|||
],
|
||||
);
|
||||
|
||||
export const ssoProvider = sqliteTable("sso_provider", {
|
||||
id: text("id").primaryKey(),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").references(() => usersTable.id, { onDelete: "set null" }),
|
||||
issuer: text("issuer").notNull(),
|
||||
domain: text("domain").notNull(),
|
||||
autoLinkMatchingEmails: int("auto_link_matching_emails", { mode: "boolean" }).notNull().default(false),
|
||||
oidcConfig: text("oidc_config", { mode: "json" }).$type<Record<string, unknown> | null>(),
|
||||
samlConfig: text("saml_config", { mode: "json" }).$type<Record<string, unknown> | null>(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.$onUpdate(() => new Date())
|
||||
.default(sql`(unixepoch() * 1000)`),
|
||||
});
|
||||
|
||||
/**
|
||||
* Volumes Table
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,20 +4,24 @@ import {
|
|||
type BetterAuthOptions,
|
||||
type MiddlewareContext,
|
||||
type MiddlewareOptions,
|
||||
type User,
|
||||
} from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { admin, createAuthMiddleware, twoFactor, username, organization } from "better-auth/plugins";
|
||||
import { UnauthorizedError } from "http-errors-enhanced";
|
||||
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { config } from "../core/config";
|
||||
import { db } from "../db/db";
|
||||
import { cryptoUtils } from "../utils/crypto";
|
||||
import { organization as organizationTable, member, usersTable } from "../db/schema";
|
||||
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
|
||||
import { authService } from "../modules/auth/auth.service";
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||
import { isValidUsername, normalizeUsername } from "~/lib/username";
|
||||
import { ensureOnlyOneUser } from "./auth/middlewares/only-one-user";
|
||||
import { convertLegacyUserOnFirstLogin } from "./auth/middlewares/convert-legacy-user";
|
||||
import { validateSsoCallbackUrls } from "./auth/middlewares/validate-sso-callback-urls";
|
||||
import { validateSsoProviderId } from "./auth/middlewares/validate-sso-provider-id";
|
||||
import { createUserDefaultOrg } from "./auth/helpers/create-default-org";
|
||||
import { isSsoCallbackRequest, requireSsoInvitation } from "./auth/middlewares/require-sso-invitation";
|
||||
import { ssoTrustedProviderLinkingPlugin } from "./auth/plugins/sso-trusted-provider-linking";
|
||||
|
||||
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||
|
||||
|
|
@ -34,6 +38,8 @@ export const auth = betterAuth({
|
|||
},
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
await validateSsoProviderId(ctx);
|
||||
await validateSsoCallbackUrls(ctx);
|
||||
await ensureOnlyOneUser(ctx);
|
||||
await convertLegacyUserOnFirstLogin(ctx);
|
||||
}),
|
||||
|
|
@ -49,7 +55,12 @@ export const auth = betterAuth({
|
|||
},
|
||||
},
|
||||
create: {
|
||||
before: async (user) => {
|
||||
before: async (user, ctx) => {
|
||||
if (isSsoCallbackRequest(ctx)) {
|
||||
await requireSsoInvitation(user.email, ctx);
|
||||
user.hasDownloadedResticPassword = true;
|
||||
}
|
||||
|
||||
const anyUser = await db.query.usersTable.findFirst();
|
||||
const isFirstUser = !anyUser;
|
||||
|
||||
|
|
@ -57,65 +68,19 @@ export const auth = betterAuth({
|
|||
user.role = "admin";
|
||||
}
|
||||
|
||||
return { data: user };
|
||||
},
|
||||
after: async (user) => {
|
||||
const slug = user.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4);
|
||||
|
||||
const resticPassword = cryptoUtils.generateResticPassword();
|
||||
const metadata = {
|
||||
resticPassword: await cryptoUtils.sealSecret(resticPassword),
|
||||
};
|
||||
|
||||
try {
|
||||
db.transaction((tx) => {
|
||||
const orgId = Bun.randomUUIDv7();
|
||||
|
||||
tx.insert(organizationTable)
|
||||
.values({
|
||||
name: `${user.name}'s Workspace`,
|
||||
slug: slug,
|
||||
id: orgId,
|
||||
createdAt: new Date(),
|
||||
metadata,
|
||||
})
|
||||
.run();
|
||||
|
||||
tx.insert(member)
|
||||
.values({
|
||||
id: Bun.randomUUIDv7(),
|
||||
userId: user.id,
|
||||
role: "owner",
|
||||
organizationId: orgId,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
});
|
||||
} catch {
|
||||
await db.delete(usersTable).where(eq(usersTable.id, user.id));
|
||||
|
||||
throw new Error(`Failed to create organization for user ${user.id}`);
|
||||
if (!user.username) {
|
||||
user.username = Bun.randomUUIDv7();
|
||||
}
|
||||
|
||||
return { data: user };
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
const orgMembership = await db.query.member.findFirst({
|
||||
where: { userId: session.userId },
|
||||
});
|
||||
|
||||
if (!orgMembership) {
|
||||
throw new UnauthorizedError("User does not belong to any organization");
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
activeOrganizationId: orgMembership?.organizationId,
|
||||
},
|
||||
};
|
||||
before: async (session, ctx) => {
|
||||
const membership = await createUserDefaultOrg(session.userId, ctx);
|
||||
return { data: { ...session, activeOrganizationId: membership.organizationId } };
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -123,6 +88,11 @@ export const auth = betterAuth({
|
|||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
modelName: "usersTable",
|
||||
additionalFields: {
|
||||
|
|
@ -151,6 +121,22 @@ export const auth = betterAuth({
|
|||
organization({
|
||||
allowUserToCreateOrganization: false,
|
||||
}),
|
||||
sso({
|
||||
trustEmailVerified: false,
|
||||
providersLimit: async (user: User) => {
|
||||
const existingUser = await db.query.usersTable.findFirst({
|
||||
columns: { role: true },
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
return existingUser?.role === "admin" ? 10 : 0;
|
||||
},
|
||||
organizationProvisioning: {
|
||||
disabled: false,
|
||||
defaultRole: "member",
|
||||
},
|
||||
}),
|
||||
ssoTrustedProviderLinkingPlugin(),
|
||||
twoFactor({
|
||||
backupCodeOptions: {
|
||||
storeBackupCodes: "encrypted",
|
||||
|
|
|
|||
161
app/server/lib/auth/helpers/__tests__/create-default-org.test.ts
Normal file
161
app/server/lib/auth/helpers/__tests__/create-default-org.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { createUserDefaultOrg } from "../create-default-org";
|
||||
|
||||
function createMockContext(path: string, params: Record<string, string> = {}): GenericEndpointContext {
|
||||
return {
|
||||
path,
|
||||
body: {},
|
||||
query: {},
|
||||
headers: new Headers(),
|
||||
request: new Request(`http://localhost:3000${path}`),
|
||||
params,
|
||||
method: "POST",
|
||||
context: {} as GenericEndpointContext["context"],
|
||||
} as GenericEndpointContext;
|
||||
}
|
||||
|
||||
function createMockSsoCallbackContext(providerId: string): GenericEndpointContext {
|
||||
return createMockContext(`/sso/callback/${providerId}`, { providerId });
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
||||
function randomSlug(prefix: string) {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(email: string, username: string) {
|
||||
const userId = randomId();
|
||||
await db.insert(usersTable).values({
|
||||
id: userId,
|
||||
email,
|
||||
name: username,
|
||||
username,
|
||||
});
|
||||
return userId;
|
||||
}
|
||||
|
||||
describe("createUserDefaultOrg", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(invitation);
|
||||
await db.delete(ssoProvider);
|
||||
await db.delete(organization);
|
||||
await db.delete(usersTable);
|
||||
});
|
||||
|
||||
test("creates invited membership from SSO callback request context", async () => {
|
||||
const invitedUserId = await createUser("invited@example.com", randomSlug("invited"));
|
||||
const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
|
||||
const organizationId = randomId();
|
||||
|
||||
await db.insert(organization).values({
|
||||
id: organizationId,
|
||||
name: "Acme",
|
||||
slug: randomSlug("acme"),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await db.insert(ssoProvider).values({
|
||||
id: randomId(),
|
||||
providerId: "oidc-acme",
|
||||
organizationId,
|
||||
userId: inviterId,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
});
|
||||
|
||||
await db.insert(invitation).values({
|
||||
id: randomId(),
|
||||
organizationId,
|
||||
email: "invited@example.com",
|
||||
role: "member",
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
createdAt: new Date(),
|
||||
inviterId,
|
||||
});
|
||||
|
||||
const ctx = createMockSsoCallbackContext("oidc-acme");
|
||||
const membership = await createUserDefaultOrg(invitedUserId, ctx);
|
||||
|
||||
expect(membership.organizationId).toBe(organizationId);
|
||||
expect(membership.role).toBe("member");
|
||||
|
||||
const updatedInvitations = await db.select().from(invitation).where(eq(invitation.organizationId, organizationId));
|
||||
const updatedInvitation = updatedInvitations.find((i) => i.email === "invited@example.com");
|
||||
expect(updatedInvitation?.status).toBe("accepted");
|
||||
});
|
||||
|
||||
test("blocks SSO callback users without pending invitations", async () => {
|
||||
const userId = await createUser("new-user@example.com", randomSlug("new-user"));
|
||||
const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
|
||||
const organizationId = randomId();
|
||||
|
||||
await db.insert(organization).values({
|
||||
id: organizationId,
|
||||
name: "Acme",
|
||||
slug: randomSlug("acme"),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await db.insert(ssoProvider).values({
|
||||
id: randomId(),
|
||||
providerId: "oidc-acme",
|
||||
organizationId,
|
||||
userId: inviterId,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
});
|
||||
|
||||
const ctx = createMockSsoCallbackContext("oidc-acme");
|
||||
expect(createUserDefaultOrg(userId, ctx)).rejects.toThrow("invite-only");
|
||||
});
|
||||
|
||||
test("returns existing membership without creating another workspace", async () => {
|
||||
const userId = await createUser("existing-member@example.com", randomSlug("existing-member"));
|
||||
const organizationId = randomId();
|
||||
|
||||
await db.insert(organization).values({
|
||||
id: organizationId,
|
||||
name: "Existing Org",
|
||||
slug: randomSlug("existing"),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await db.insert(member).values({
|
||||
id: randomId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const membership = await createUserDefaultOrg(userId, null);
|
||||
|
||||
expect(membership.organizationId).toBe(organizationId);
|
||||
expect(membership.role).toBe("owner");
|
||||
|
||||
const memberships = await db.select().from(member).where(eq(member.userId, userId));
|
||||
expect(memberships.length).toBe(1);
|
||||
|
||||
const organizations = await db.select().from(organization);
|
||||
expect(organizations.length).toBe(1);
|
||||
});
|
||||
|
||||
test("creates personal workspace for non-SSO flows", async () => {
|
||||
const userId = await createUser("local-user@example.com", randomSlug("local-user"));
|
||||
|
||||
const membership = await createUserDefaultOrg(userId, null);
|
||||
|
||||
expect(membership.role).toBe("owner");
|
||||
expect(membership.organization.name).toContain("Workspace");
|
||||
});
|
||||
});
|
||||
172
app/server/lib/auth/helpers/create-default-org.ts
Normal file
172
app/server/lib/auth/helpers/create-default-org.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { UnauthorizedError } from "http-errors-enhanced";
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { db } from "~/server/db/db";
|
||||
import { invitation, member, organization, ssoProvider, usersTable, type User } from "~/server/db/schema";
|
||||
import { cryptoUtils } from "~/server/utils/crypto";
|
||||
import { APIError } from "better-auth";
|
||||
import { extractProviderIdFromContext, normalizeEmail } from "../utils/sso-context";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
|
||||
export async function findMembershipWithOrganization(userId: string, organizationId?: string) {
|
||||
const memberships = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
organizationId
|
||||
? and(eq(member.userId, userId), eq(member.organizationId, organizationId))
|
||||
: eq(member.userId, userId),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const membership = memberships[0];
|
||||
|
||||
if (!membership) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orgs = await db.select().from(organization).where(eq(organization.id, membership.organizationId)).limit(1);
|
||||
const org = orgs[0];
|
||||
|
||||
if (!org) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...membership, organization: org };
|
||||
}
|
||||
|
||||
function buildOrgSlug(email: string) {
|
||||
const [emailPrefix] = email.split("@");
|
||||
const sanitized = emailPrefix
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const safePrefix = sanitized || "org";
|
||||
return `${safePrefix}-${Math.random().toString(36).slice(-4)}`;
|
||||
}
|
||||
|
||||
async function tryCreateInvitedMembership(userId: string, email: string, ctx: GenericEndpointContext | null) {
|
||||
logger.debug("Checking for pending invitations for user", userId);
|
||||
|
||||
const providerId = extractProviderIdFromContext(ctx);
|
||||
const ssoProviders = await db.select().from(ssoProvider).where(eq(ssoProvider.providerId, providerId)).limit(1);
|
||||
const ssoProviderRecord = ssoProviders[0];
|
||||
|
||||
if (!ssoProviderRecord) {
|
||||
logger.debug("No SSO provider found in context, skipping invitation check");
|
||||
return null;
|
||||
}
|
||||
logger.debug("SSO provider found in context, checking for linked accounts", ssoProviderRecord.providerId);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const pendingInvitations = await db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
organizationId: invitation.organizationId,
|
||||
})
|
||||
.from(invitation)
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.status, "pending"),
|
||||
eq(invitation.organizationId, ssoProviderRecord.organizationId),
|
||||
gt(invitation.expiresAt, now),
|
||||
eq(invitation.email, normalizeEmail(email)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const pendingInvitation = pendingInvitations[0];
|
||||
|
||||
if (!pendingInvitation) {
|
||||
logger.debug("No pending invitation found for user");
|
||||
throw new APIError("FORBIDDEN", { message: "SSO sign-in is invite-only for this organization" });
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
tx.insert(member)
|
||||
.values({
|
||||
id: Bun.randomUUIDv7(),
|
||||
userId,
|
||||
role: pendingInvitation.role as "member",
|
||||
organizationId: pendingInvitation.organizationId,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
|
||||
tx.update(invitation).set({ status: "accepted" }).where(eq(invitation.id, pendingInvitation.id)).run();
|
||||
});
|
||||
|
||||
const invitedMembership = await findMembershipWithOrganization(userId, pendingInvitation.organizationId);
|
||||
logger.debug("Created organization membership from invitation", {
|
||||
userId,
|
||||
organizationId: pendingInvitation.organizationId,
|
||||
});
|
||||
|
||||
if (!invitedMembership) {
|
||||
throw new Error("Failed to create invited organization membership");
|
||||
}
|
||||
|
||||
return invitedMembership;
|
||||
}
|
||||
|
||||
async function createDefaultOrganizationMembership(user: User) {
|
||||
logger.debug("Creating default organization for user", { userId: user.id });
|
||||
const resticPassword = cryptoUtils.generateResticPassword();
|
||||
const metadata = { resticPassword: await cryptoUtils.sealSecret(resticPassword) };
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const orgId = Bun.randomUUIDv7();
|
||||
const slug = buildOrgSlug(user.email);
|
||||
|
||||
tx.insert(organization)
|
||||
.values({
|
||||
name: `${user.name}'s Workspace`,
|
||||
slug,
|
||||
id: orgId,
|
||||
createdAt: new Date(),
|
||||
metadata,
|
||||
})
|
||||
.run();
|
||||
|
||||
tx.insert(member)
|
||||
.values({
|
||||
id: Bun.randomUUIDv7(),
|
||||
userId: user.id,
|
||||
role: "owner",
|
||||
organizationId: orgId,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUserDefaultOrg(userId: string, ctx: GenericEndpointContext | null) {
|
||||
const users = await db.select().from(usersTable).where(eq(usersTable.id, userId)).limit(1);
|
||||
const user = users[0];
|
||||
if (!user) {
|
||||
throw new UnauthorizedError("User not found");
|
||||
}
|
||||
|
||||
const existingMembership = await findMembershipWithOrganization(user.id);
|
||||
if (existingMembership) {
|
||||
logger.debug("User already has an organization membership, skipping default org creation", { userId });
|
||||
return existingMembership;
|
||||
}
|
||||
|
||||
const invitedMembership = await tryCreateInvitedMembership(userId, normalizeEmail(user.email), ctx);
|
||||
if (invitedMembership) {
|
||||
return invitedMembership;
|
||||
}
|
||||
|
||||
await createDefaultOrganizationMembership(user);
|
||||
|
||||
const newMembership = await findMembershipWithOrganization(userId);
|
||||
if (!newMembership) {
|
||||
throw new Error("Failed to create default organization");
|
||||
}
|
||||
|
||||
return newMembership;
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { test, describe, mock, beforeEach, afterEach, expect } from "bun:test";
|
||||
import { test, describe, beforeEach, expect } from "bun:test";
|
||||
import { convertLegacyUserOnFirstLogin } from "../convert-legacy-user";
|
||||
import { db } from "~/server/db/db";
|
||||
import { usersTable, account, organization, member } from "~/server/db/schema";
|
||||
import type { AuthMiddlewareContext } from "../../auth";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
|
||||
describe("convertLegacyUserOnFirstLogin", () => {
|
||||
beforeEach(async () => {
|
||||
|
|
@ -12,10 +12,6 @@ describe("convertLegacyUserOnFirstLogin", () => {
|
|||
await db.delete(usersTable);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
const createContext = (path: string, body: Record<string, string>) => ({ path, body }) as AuthMiddlewareContext;
|
||||
|
||||
test("should return early for non-sign-in paths", async () => {
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { isSsoCallbackRequest, requireSsoInvitation } from "../require-sso-invitation";
|
||||
|
||||
function createMockContext(path: string, params: Record<string, string> = {}): GenericEndpointContext {
|
||||
return {
|
||||
path,
|
||||
body: {},
|
||||
query: {},
|
||||
headers: new Headers(),
|
||||
request: new Request(`http://test.local${path}`),
|
||||
params,
|
||||
method: "POST",
|
||||
context: {} as GenericEndpointContext["context"],
|
||||
} as GenericEndpointContext;
|
||||
}
|
||||
|
||||
function createMockSsoCallbackContext(providerId: string): GenericEndpointContext {
|
||||
return createMockContext(`/sso/callback/${providerId}`, { providerId });
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
||||
function randomSlug(prefix: string) {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createSsoProvider(providerId: string) {
|
||||
const inviterId = randomId();
|
||||
const organizationId = randomId();
|
||||
|
||||
await db.insert(usersTable).values({
|
||||
id: inviterId,
|
||||
username: randomSlug("inviter"),
|
||||
email: `${randomSlug("inviter")}@example.com`,
|
||||
name: "Inviter",
|
||||
});
|
||||
|
||||
await db.insert(organization).values({
|
||||
id: organizationId,
|
||||
name: "Acme",
|
||||
slug: randomSlug("acme"),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await db.insert(ssoProvider).values({
|
||||
id: randomId(),
|
||||
providerId,
|
||||
organizationId,
|
||||
userId: inviterId,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
});
|
||||
|
||||
return { inviterId, organizationId };
|
||||
}
|
||||
|
||||
describe("requireSsoInvitation", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(invitation);
|
||||
await db.delete(ssoProvider);
|
||||
await db.delete(organization);
|
||||
await db.delete(usersTable);
|
||||
});
|
||||
|
||||
test("throws when context is null", async () => {
|
||||
await createSsoProvider("oidc-acme");
|
||||
|
||||
expect(requireSsoInvitation("user@example.com", null)).rejects.toThrow("Missing SSO context");
|
||||
});
|
||||
|
||||
test("throws when request is not an SSO callback", async () => {
|
||||
await createSsoProvider("oidc-acme");
|
||||
|
||||
const ctx = createMockContext("/sign-up/email");
|
||||
expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("Missing providerId");
|
||||
});
|
||||
|
||||
test("detects whether current request is an SSO callback", async () => {
|
||||
expect(isSsoCallbackRequest(null)).toBe(false);
|
||||
|
||||
const nonSsoResult = isSsoCallbackRequest(createMockContext("/sign-up/email"));
|
||||
expect(nonSsoResult).toBe(false);
|
||||
|
||||
const ssoResult = isSsoCallbackRequest(createMockSsoCallbackContext("oidc-acme"));
|
||||
expect(ssoResult).toBe(true);
|
||||
});
|
||||
|
||||
test("blocks SSO callback when no pending invitation exists", async () => {
|
||||
await createSsoProvider("oidc-acme");
|
||||
|
||||
const ctx = createMockSsoCallbackContext("oidc-acme");
|
||||
expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited");
|
||||
});
|
||||
|
||||
test("blocks SSO callback when invitation is expired", async () => {
|
||||
const { inviterId, organizationId } = await createSsoProvider("oidc-acme");
|
||||
|
||||
await db.insert(invitation).values({
|
||||
id: randomId(),
|
||||
organizationId,
|
||||
email: "user@example.com",
|
||||
role: "member",
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() - 1_000),
|
||||
createdAt: new Date(),
|
||||
inviterId,
|
||||
});
|
||||
|
||||
const ctx = createMockSsoCallbackContext("oidc-acme");
|
||||
expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("must be invited");
|
||||
});
|
||||
|
||||
test("allows SSO callback when a matching pending invitation exists", async () => {
|
||||
const { inviterId, organizationId } = await createSsoProvider("oidc-acme");
|
||||
|
||||
await db.insert(invitation).values({
|
||||
id: randomId(),
|
||||
organizationId,
|
||||
email: "user@example.com",
|
||||
role: "member",
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
createdAt: new Date(),
|
||||
inviterId,
|
||||
});
|
||||
|
||||
const ctx = createMockSsoCallbackContext("oidc-acme");
|
||||
expect(requireSsoInvitation(" USER@EXAMPLE.COM ", ctx)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("throws when provider is not registered", async () => {
|
||||
const ctx = createMockSsoCallbackContext("missing-provider");
|
||||
expect(requireSsoInvitation("user@example.com", ctx)).rejects.toThrow("SSO provider not found");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { isSsoCallbackPath, trustSsoProviderForLinking } from "../trust-sso-provider-for-linking";
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
||||
function randomSlug(prefix: string) {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function createMockContext(options: {
|
||||
path: string;
|
||||
method?: string;
|
||||
params?: Record<string, string>;
|
||||
trustedProviders?: string[];
|
||||
enabled?: boolean;
|
||||
}): GenericEndpointContext {
|
||||
const { path, method = "GET", params = {}, trustedProviders = [], enabled = true } = options;
|
||||
|
||||
const accountLinking = {
|
||||
enabled,
|
||||
trustedProviders,
|
||||
};
|
||||
|
||||
const context = {
|
||||
options: {
|
||||
account: {
|
||||
accountLinking,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
body: {},
|
||||
query: {},
|
||||
headers: new Headers(),
|
||||
request: new Request(`http://test.local${path}`, { method }),
|
||||
params,
|
||||
method,
|
||||
context: context as GenericEndpointContext["context"],
|
||||
} as GenericEndpointContext;
|
||||
}
|
||||
|
||||
async function createSsoProviderRecord(
|
||||
providerId: string,
|
||||
autoLinkMatchingEmails: boolean,
|
||||
options: { organizationId?: string; userId?: string } = {},
|
||||
) {
|
||||
const userId = options.userId ?? randomId();
|
||||
const organizationId = options.organizationId ?? randomId();
|
||||
|
||||
if (!options.userId) {
|
||||
await db.insert(usersTable).values({
|
||||
id: userId,
|
||||
username: randomSlug("inviter"),
|
||||
email: `${randomSlug("inviter")}@example.com`,
|
||||
name: "Inviter",
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.organizationId) {
|
||||
await db.insert(organization).values({
|
||||
id: organizationId,
|
||||
name: "Acme",
|
||||
slug: randomSlug("acme"),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(ssoProvider).values({
|
||||
id: randomId(),
|
||||
providerId,
|
||||
organizationId,
|
||||
userId,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
autoLinkMatchingEmails,
|
||||
});
|
||||
|
||||
return { organizationId, userId };
|
||||
}
|
||||
|
||||
describe("isSsoCallbackPath", () => {
|
||||
test("detects OIDC callback paths", () => {
|
||||
expect(isSsoCallbackPath("/sso/callback/pocket-id")).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores non-callback paths", () => {
|
||||
expect(isSsoCallbackPath("/sso/register")).toBe(false);
|
||||
expect(isSsoCallbackPath("/sign-in/email")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trustSsoProviderForLinking", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(ssoProvider);
|
||||
await db.delete(organization);
|
||||
await db.delete(usersTable);
|
||||
});
|
||||
|
||||
test("adds callback provider to trusted providers", async () => {
|
||||
await createSsoProviderRecord("pocket-id", true);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toContain("pocket-id");
|
||||
});
|
||||
|
||||
test("does not trust providers with disabled auto-linking", async () => {
|
||||
await createSsoProviderRecord("pocket-id", false);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
||||
});
|
||||
|
||||
test("replaces stale trusted providers with database state", async () => {
|
||||
await createSsoProviderRecord("pocket-id", true);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
trustedProviders: ["stale-provider"],
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual(["pocket-id"]);
|
||||
});
|
||||
|
||||
test("does not trust unknown providers", async () => {
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/missing-provider",
|
||||
params: { providerId: "missing-provider" },
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not duplicate an existing provider", async () => {
|
||||
await createSsoProviderRecord("pocket-id", true);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
trustedProviders: ["pocket-id"],
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual(["pocket-id"]);
|
||||
});
|
||||
|
||||
test("removes provider from trusted providers when auto-linking is disabled", async () => {
|
||||
await createSsoProviderRecord("pocket-id", true);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual(["pocket-id"]);
|
||||
|
||||
await db.update(ssoProvider).set({ autoLinkMatchingEmails: false }).where(eq(ssoProvider.providerId, "pocket-id"));
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
||||
});
|
||||
|
||||
test("does nothing when account linking is disabled", async () => {
|
||||
await createSsoProviderRecord("pocket-id", true);
|
||||
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/pocket-id",
|
||||
params: { providerId: "pocket-id" },
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
||||
});
|
||||
|
||||
test("does nothing when provider id cannot be extracted", async () => {
|
||||
const ctx = createMockContext({
|
||||
path: "/sso/callback/",
|
||||
params: {},
|
||||
});
|
||||
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
|
||||
expect(ctx.context.options.account?.accountLinking?.trustedProviders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
import { validateSsoCallbackUrls } from "../validate-sso-callback-urls";
|
||||
|
||||
function createContext(
|
||||
path: string,
|
||||
body: Record<string, unknown> = {},
|
||||
query: Record<string, unknown> = {},
|
||||
): AuthMiddlewareContext {
|
||||
return {
|
||||
path,
|
||||
body,
|
||||
query,
|
||||
headers: new Headers(),
|
||||
request: new Request(`http://localhost:3000${path}`),
|
||||
params: {},
|
||||
method: "POST",
|
||||
context: {} as AuthMiddlewareContext["context"],
|
||||
} as AuthMiddlewareContext;
|
||||
}
|
||||
|
||||
describe("validateSsoCallbackUrls", () => {
|
||||
test("accepts relative paths for every callback field", async () => {
|
||||
const ctx = createContext("/sign-in/sso", {
|
||||
callbackURL: "/login",
|
||||
errorCallbackURL: "/login/error",
|
||||
newUserCallbackURL: "/download-recovery-key",
|
||||
});
|
||||
|
||||
expect(validateSsoCallbackUrls(ctx)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects https://evil.example", async () => {
|
||||
const ctx = createContext("/sign-in/sso", { callbackURL: "https://evil.example" });
|
||||
|
||||
expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL");
|
||||
});
|
||||
|
||||
test("rejects //evil.example", async () => {
|
||||
const ctx = createContext("/sign-in/sso", { callbackURL: "//evil.example" });
|
||||
|
||||
expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL");
|
||||
});
|
||||
|
||||
test("rejects /sso/callback/foo", async () => {
|
||||
const ctx = createContext("/sign-in/sso", { callbackURL: "/sso/callback/foo" });
|
||||
|
||||
expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL");
|
||||
});
|
||||
|
||||
test("rejects /sso/saml2/foo", async () => {
|
||||
const ctx = createContext("/sign-in/sso", { callbackURL: "/sso/saml2/foo" });
|
||||
|
||||
expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("callbackURL");
|
||||
});
|
||||
|
||||
test("rejects malicious query callback fields", async () => {
|
||||
const ctx = createContext("/sign-in/sso", {}, { errorCallbackURL: "https://evil.example" });
|
||||
|
||||
expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("errorCallbackURL");
|
||||
});
|
||||
|
||||
test("rejects malicious newUserCallbackURL", async () => {
|
||||
const ctx = createContext("/sign-in/sso", { newUserCallbackURL: "https://evil.example" });
|
||||
|
||||
expect(validateSsoCallbackUrls(ctx)).rejects.toThrow("newUserCallbackURL");
|
||||
});
|
||||
|
||||
test("skips validation outside SSO sign-in endpoint", async () => {
|
||||
const ctx = createContext("/sign-in/email", { callbackURL: "https://evil.example" });
|
||||
|
||||
expect(validateSsoCallbackUrls(ctx)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
import { validateSsoProviderId } from "../validate-sso-provider-id";
|
||||
|
||||
function createContext(path: string, body: Record<string, unknown> = {}): AuthMiddlewareContext {
|
||||
return {
|
||||
path,
|
||||
body,
|
||||
query: {},
|
||||
headers: new Headers(),
|
||||
request: new Request(`http://localhost:3000${path}`),
|
||||
params: {},
|
||||
method: "POST",
|
||||
context: {} as AuthMiddlewareContext["context"],
|
||||
} as AuthMiddlewareContext;
|
||||
}
|
||||
|
||||
describe("validateSsoProviderId", () => {
|
||||
test("allows non-reserved provider id", async () => {
|
||||
const ctx = createContext("/sso/register", { providerId: "acme-oidc" });
|
||||
|
||||
expect(validateSsoProviderId(ctx)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects reserved credential provider id", async () => {
|
||||
const ctx = createContext("/sso/register", { providerId: "credential" });
|
||||
|
||||
expect(validateSsoProviderId(ctx)).rejects.toThrow("reserved");
|
||||
});
|
||||
|
||||
test("rejects reserved credentials provider id case-insensitively", async () => {
|
||||
const ctx = createContext("/sso/register", { providerId: " Credential " });
|
||||
|
||||
expect(validateSsoProviderId(ctx)).rejects.toThrow("reserved");
|
||||
});
|
||||
|
||||
test("skips validation outside register endpoint", async () => {
|
||||
const ctx = createContext("/sign-in/sso", { providerId: "credential" });
|
||||
|
||||
expect(validateSsoProviderId(ctx)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import { hashPassword } from "better-auth/crypto";
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, usersTable, member, organization } from "~/server/db/schema";
|
||||
import type { AuthMiddlewareContext } from "../auth";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
import { UnauthorizedError } from "http-errors-enhanced";
|
||||
import { cryptoUtils } from "~/server/utils/crypto";
|
||||
import { normalizeUsername } from "~/lib/username";
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import type { AuthMiddlewareContext } from "../auth";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { ForbiddenError } from "http-errors-enhanced";
|
||||
import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
|
||||
|
||||
export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
|
||||
const { path } = ctx;
|
||||
const existingUser = await db.query.usersTable.findFirst();
|
||||
|
||||
if (path !== "/sign-up/email") {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUser = await db.query.usersTable.findFirst();
|
||||
|
||||
const result = await db.query.appMetadataTable.findFirst({
|
||||
where: { key: REGISTRATION_ENABLED_KEY },
|
||||
});
|
||||
52
app/server/lib/auth/middlewares/require-sso-invitation.ts
Normal file
52
app/server/lib/auth/middlewares/require-sso-invitation.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { APIError } from "better-auth/api";
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { db } from "~/server/db/db";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { extractProviderIdFromContext, extractProviderIdFromUrl, normalizeEmail } from "../utils/sso-context";
|
||||
|
||||
export function isSsoCallbackRequest(ctx: GenericEndpointContext | null) {
|
||||
if (!ctx?.request?.url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return extractProviderIdFromUrl(ctx.request.url) !== null;
|
||||
}
|
||||
|
||||
export const requireSsoInvitation = async (userEmail: string, ctx: GenericEndpointContext | null) => {
|
||||
if (!ctx) {
|
||||
throw new APIError("BAD_REQUEST", { message: "Missing SSO context" });
|
||||
}
|
||||
|
||||
const providerId = extractProviderIdFromContext(ctx);
|
||||
if (!providerId) {
|
||||
throw new APIError("BAD_REQUEST", { message: "Missing providerId in context" });
|
||||
}
|
||||
|
||||
const provider = await db.query.ssoProvider.findFirst({ where: { providerId } });
|
||||
if (!provider) {
|
||||
throw new APIError("NOT_FOUND", { message: "SSO provider not found" });
|
||||
}
|
||||
|
||||
const normalizedEmail = normalizeEmail(userEmail);
|
||||
logger.debug("Checking for pending invitations", { organizationId: provider.organizationId });
|
||||
|
||||
const pendingInvitation = await db.query.invitation.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ organizationId: provider.organizationId },
|
||||
{ status: "pending" },
|
||||
{ expiresAt: { gt: new Date() } },
|
||||
{ email: normalizedEmail },
|
||||
],
|
||||
},
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
logger.debug("Pending invitation result", { found: !!pendingInvitation, invitationId: pendingInvitation?.id });
|
||||
|
||||
if (!pendingInvitation) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Access denied. You must be invited to this organization before you can sign in with SSO.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { db } from "~/server/db/db";
|
||||
import { extractProviderIdFromContext } from "../utils/sso-context";
|
||||
|
||||
export function isSsoCallbackPath(path?: string): boolean {
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return path.startsWith("/sso/callback/");
|
||||
}
|
||||
|
||||
export async function trustSsoProviderForLinking(ctx: GenericEndpointContext): Promise<void> {
|
||||
const providerId = extractProviderIdFromContext(ctx);
|
||||
|
||||
if (!providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountLinking = ctx.context.options.account?.accountLinking;
|
||||
|
||||
if (!accountLinking || accountLinking.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = await db.query.ssoProvider.findFirst({
|
||||
columns: { organizationId: true },
|
||||
where: { providerId },
|
||||
});
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoLinkingProviders = await db.query.ssoProvider.findMany({
|
||||
columns: { providerId: true },
|
||||
where: {
|
||||
organizationId: provider.organizationId,
|
||||
autoLinkMatchingEmails: true,
|
||||
},
|
||||
});
|
||||
|
||||
accountLinking.trustedProviders = autoLinkingProviders.map((entry) => entry.providerId);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { APIError } from "better-auth/api";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
|
||||
function isValidCallbackPath(value: string): boolean {
|
||||
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.startsWith("/sso/callback/") || value.startsWith("/sso/saml2/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const validateSsoCallbackUrls = async (ctx: AuthMiddlewareContext) => {
|
||||
if (ctx.path !== "/sign-in/sso") {
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = [ctx.body, ctx.query].filter((s) => s && typeof s === "object");
|
||||
|
||||
for (const source of sources) {
|
||||
const payload = source as Record<string, unknown>;
|
||||
|
||||
for (const field of ["callbackURL", "errorCallbackURL", "newUserCallbackURL"]) {
|
||||
const value = payload[field];
|
||||
|
||||
if (value !== undefined && (typeof value !== "string" || !isValidCallbackPath(value))) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: `Invalid ${field}. Only relative paths like /login are allowed.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
25
app/server/lib/auth/middlewares/validate-sso-provider-id.ts
Normal file
25
app/server/lib/auth/middlewares/validate-sso-provider-id.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { APIError } from "better-auth/api";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
import { isReservedSsoProviderId } from "../utils/sso-provider-id";
|
||||
|
||||
export const validateSsoProviderId = async (ctx: AuthMiddlewareContext) => {
|
||||
if (ctx.path !== "/sso/register") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.body || typeof ctx.body !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerId = (ctx.body as Record<string, unknown>).providerId;
|
||||
|
||||
if (typeof providerId !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReservedSsoProviderId(providerId)) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: `Invalid providerId. '${providerId}' is reserved and cannot be used for SSO providers.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
21
app/server/lib/auth/plugins/sso-trusted-provider-linking.ts
Normal file
21
app/server/lib/auth/plugins/sso-trusted-provider-linking.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { BetterAuthPlugin } from "better-auth";
|
||||
import { createAuthMiddleware } from "better-auth/api";
|
||||
import { isSsoCallbackPath, trustSsoProviderForLinking } from "../middlewares/trust-sso-provider-for-linking";
|
||||
|
||||
export function ssoTrustedProviderLinkingPlugin(): BetterAuthPlugin {
|
||||
return {
|
||||
id: "sso-trusted-provider-linking",
|
||||
hooks: {
|
||||
before: [
|
||||
{
|
||||
matcher(context) {
|
||||
return isSsoCallbackPath(context.path);
|
||||
},
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
await trustSsoProviderForLinking(ctx);
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
31
app/server/lib/auth/utils/sso-context.ts
Normal file
31
app/server/lib/auth/utils/sso-context.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function extractProviderIdFromUrl(url: string): string | null {
|
||||
try {
|
||||
const pathname = new URL(url, "http://localhost").pathname;
|
||||
const match = pathname.match(/\/sso\/(?:saml2\/)?callback\/([^/]+)$/);
|
||||
return match?.[1] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractProviderIdFromContext(ctx?: GenericEndpointContext | null) {
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ctx.params?.providerId) {
|
||||
return ctx.params.providerId;
|
||||
}
|
||||
|
||||
if (ctx.request?.url) {
|
||||
return extractProviderIdFromUrl(ctx.request.url);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
9
app/server/lib/auth/utils/sso-provider-id.ts
Normal file
9
app/server/lib/auth/utils/sso-provider-id.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const RESERVED_SSO_PROVIDER_IDS = new Set(["credential"]);
|
||||
|
||||
export function normalizeSsoProviderId(providerId: string): string {
|
||||
return providerId.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isReservedSsoProviderId(providerId: string): boolean {
|
||||
return RESERVED_SSO_PROVIDER_IDS.has(normalizeSsoProviderId(providerId));
|
||||
}
|
||||
31
app/server/lib/functions/organization-context.ts
Normal file
31
app/server/lib/functions/organization-context.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { auth } from "../auth";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
|
||||
export const getOrganizationContext = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const request = getRequest();
|
||||
|
||||
const [data, session] = await Promise.all([
|
||||
auth.api.listOrganizations({
|
||||
headers: request.headers,
|
||||
}),
|
||||
auth.api.getSession({ headers: request.headers }),
|
||||
]);
|
||||
|
||||
const activeOrganizationId = session?.session?.activeOrganizationId;
|
||||
const activeOrganization = data.find((org) => org.id === activeOrganizationId);
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new Error("No organizations found for user");
|
||||
}
|
||||
|
||||
const member = await auth.api.getActiveMember({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
return {
|
||||
organizations: data,
|
||||
activeOrganization: activeOrganization || data[0],
|
||||
activeMember: member,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { db } from "~/server/db/db";
|
||||
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { authService } from "../auth.service";
|
||||
|
||||
function randomId() {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
||||
function randomSlug(prefix: string) {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(email: string) {
|
||||
const id = randomId();
|
||||
|
||||
await db.insert(usersTable).values({
|
||||
id,
|
||||
email,
|
||||
name: email.split("@")[0],
|
||||
username: randomSlug("user"),
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createOrganization(name: string) {
|
||||
const id = randomId();
|
||||
|
||||
await db.insert(organization).values({
|
||||
id,
|
||||
name,
|
||||
slug: randomSlug("org"),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("authService.deleteSsoProvider", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(invitation);
|
||||
await db.delete(ssoProvider);
|
||||
await db.delete(organization);
|
||||
await db.delete(usersTable);
|
||||
});
|
||||
|
||||
test("does not delete accounts when provider belongs to another organization", async () => {
|
||||
const orgA = await createOrganization("Org A");
|
||||
const orgB = await createOrganization("Org B");
|
||||
const providerOwner = await createUser(`${randomSlug("owner")}@example.com`);
|
||||
const accountUser = await createUser(`${randomSlug("member")}@example.com`);
|
||||
|
||||
const providerId = `oidc-${randomSlug("provider")}`;
|
||||
|
||||
await db.insert(ssoProvider).values({
|
||||
id: randomId(),
|
||||
providerId,
|
||||
organizationId: orgB,
|
||||
userId: providerOwner,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
});
|
||||
|
||||
await db.insert(account).values({
|
||||
id: randomId(),
|
||||
accountId: randomSlug("acct"),
|
||||
providerId,
|
||||
userId: accountUser,
|
||||
});
|
||||
|
||||
const deleted = await authService.deleteSsoProvider(providerId, orgA);
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
|
||||
const remainingProvider = await db.query.ssoProvider.findFirst({
|
||||
where: { providerId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const remainingAccounts = await db.query.account.findMany({
|
||||
where: { providerId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(remainingProvider).not.toBeUndefined();
|
||||
expect(remainingAccounts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("deletes provider and linked accounts in the active organization", async () => {
|
||||
const org = await createOrganization("Org A");
|
||||
const providerOwner = await createUser(`${randomSlug("owner")}@example.com`);
|
||||
const accountUserA = await createUser(`${randomSlug("member-a")}@example.com`);
|
||||
const accountUserB = await createUser(`${randomSlug("member-b")}@example.com`);
|
||||
|
||||
const providerId = `oidc-${randomSlug("provider")}`;
|
||||
|
||||
await db.insert(ssoProvider).values({
|
||||
id: randomId(),
|
||||
providerId,
|
||||
organizationId: org,
|
||||
userId: providerOwner,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
});
|
||||
|
||||
await db.insert(account).values([
|
||||
{
|
||||
id: randomId(),
|
||||
accountId: randomSlug("acct-a"),
|
||||
providerId,
|
||||
userId: accountUserA,
|
||||
},
|
||||
{
|
||||
id: randomId(),
|
||||
accountId: randomSlug("acct-b"),
|
||||
providerId,
|
||||
userId: accountUserB,
|
||||
},
|
||||
]);
|
||||
|
||||
const deleted = await authService.deleteSsoProvider(providerId, org);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const remainingProvider = await db.query.ssoProvider.findFirst({
|
||||
where: { providerId },
|
||||
columns: { id: true },
|
||||
});
|
||||
const remainingAccounts = await db.query.account.findMany({
|
||||
where: { providerId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(remainingProvider).toBeUndefined();
|
||||
expect(remainingAccounts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("deleting a reserved provider id never deletes credential accounts", async () => {
|
||||
const org = await createOrganization("Org A");
|
||||
const providerOwner = await createUser(`${randomSlug("owner")}@example.com`);
|
||||
const credentialUser = await createUser(`${randomSlug("credential")}@example.com`);
|
||||
|
||||
await db.insert(ssoProvider).values({
|
||||
id: randomId(),
|
||||
providerId: "credential",
|
||||
organizationId: org,
|
||||
userId: providerOwner,
|
||||
issuer: "https://issuer.example.com",
|
||||
domain: "example.com",
|
||||
});
|
||||
|
||||
await db.insert(account).values({
|
||||
id: randomId(),
|
||||
accountId: randomSlug("credential-acct"),
|
||||
providerId: "credential",
|
||||
userId: credentialUser,
|
||||
});
|
||||
|
||||
const deleted = await authService.deleteSsoProvider("credential", org);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const remainingProvider = await db.query.ssoProvider.findFirst({
|
||||
where: { providerId: "credential" },
|
||||
columns: { id: true },
|
||||
});
|
||||
const remainingAccounts = await db.query.account.findMany({
|
||||
where: { providerId: "credential" },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
expect(remainingProvider).toBeUndefined();
|
||||
expect(remainingAccounts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
94
app/server/modules/auth/__tests__/auth.sso-security.test.ts
Normal file
94
app/server/modules/auth/__tests__/auth.sso-security.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { createApp } from "~/server/app";
|
||||
import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema";
|
||||
import { db } from "~/server/db/db";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
describe("auth SSO sign-in security", () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(member);
|
||||
await db.delete(account);
|
||||
await db.delete(invitation);
|
||||
await db.delete(ssoProvider);
|
||||
await db.delete(organization);
|
||||
await db.delete(usersTable);
|
||||
});
|
||||
|
||||
test("rejects malicious callback URL", async () => {
|
||||
const response = await app.request("/api/auth/sign-in/sso", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerId: "missing-provider",
|
||||
callbackURL: "https://evil.example",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.code).toContain("CALLBACKURL");
|
||||
expect(body.message).toContain("callbackURL");
|
||||
});
|
||||
|
||||
test("rejects malicious error callback URL", async () => {
|
||||
const response = await app.request("/api/auth/sign-in/sso", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerId: "missing-provider",
|
||||
callbackURL: "/login",
|
||||
errorCallbackURL: "https://evil.example",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.code).toContain("ERRORCALLBACKURL");
|
||||
expect(body.message).toContain("errorCallbackURL");
|
||||
});
|
||||
|
||||
test("rejects malicious new user callback URL", async () => {
|
||||
const response = await app.request("/api/auth/sign-in/sso", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerId: "missing-provider",
|
||||
callbackURL: "/login",
|
||||
newUserCallbackURL: "https://evil.example",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.code).toContain("NEWUSERCALLBACKURL");
|
||||
expect(body.message).toContain("newUserCallbackURL");
|
||||
});
|
||||
|
||||
test("allows relative callback URL to continue normal flow", async () => {
|
||||
const response = await app.request("/api/auth/sign-in/sso", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerId: "missing-provider",
|
||||
callbackURL: "/login",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("NO_PROVIDER_FOUND_FOR_THE_ISSUER");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,152 @@
|
|||
import { Hono } from "hono";
|
||||
import { type GetStatusDto, getStatusDto, getUserDeletionImpactDto, type UserDeletionImpactDto } from "./auth.dto";
|
||||
import { validator } from "hono-openapi";
|
||||
import {
|
||||
type GetStatusDto,
|
||||
getStatusDto,
|
||||
getUserDeletionImpactDto,
|
||||
type UserDeletionImpactDto,
|
||||
getPublicSsoProvidersDto,
|
||||
type PublicSsoProvidersDto,
|
||||
getSsoSettingsDto,
|
||||
type SsoSettingsDto,
|
||||
getAdminUsersDto,
|
||||
type AdminUsersDto,
|
||||
deleteSsoProviderDto,
|
||||
deleteSsoInvitationDto,
|
||||
updateSsoProviderAutoLinkingBody,
|
||||
updateSsoProviderAutoLinkingDto,
|
||||
deleteUserAccountDto,
|
||||
} from "./auth.dto";
|
||||
import { authService } from "./auth.service";
|
||||
import { requireAdmin, requireAuth } from "./auth.middleware";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
|
||||
export const authController = new Hono()
|
||||
.get("/status", getStatusDto, async (c) => {
|
||||
const hasUsers = await authService.hasUsers();
|
||||
return c.json<GetStatusDto>({ hasUsers });
|
||||
})
|
||||
.get("/sso-providers", getPublicSsoProvidersDto, async (c) => {
|
||||
const providers = await authService.getPublicSsoProviders();
|
||||
return c.json<PublicSsoProvidersDto>(providers);
|
||||
})
|
||||
.get("/sso-settings", requireAuth, requireAdmin, getSsoSettingsDto, async (c) => {
|
||||
const headers = c.req.raw.headers;
|
||||
const activeOrganizationId = c.get("organizationId");
|
||||
|
||||
if (!activeOrganizationId) {
|
||||
return c.json<SsoSettingsDto>({ providers: [], invitations: [] });
|
||||
}
|
||||
|
||||
const [providersData, invitationsData, autoLinkingSettings] = await Promise.all([
|
||||
auth.api.listSSOProviders({ headers }),
|
||||
auth.api.listInvitations({ headers, query: { organizationId: activeOrganizationId } }),
|
||||
authService.getSsoProviderAutoLinkingSettings(activeOrganizationId),
|
||||
]);
|
||||
|
||||
return c.json<SsoSettingsDto>({
|
||||
providers: providersData.providers.map((provider) => ({
|
||||
providerId: provider.providerId,
|
||||
type: provider.type,
|
||||
issuer: provider.issuer,
|
||||
domain: provider.domain,
|
||||
autoLinkMatchingEmails: autoLinkingSettings[provider.providerId] ?? false,
|
||||
organizationId: provider.organizationId,
|
||||
})),
|
||||
invitations: invitationsData.map((invitation) => ({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
status: invitation.status,
|
||||
expiresAt: invitation.expiresAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
})
|
||||
.delete("/sso-providers/:providerId", requireAuth, requireAdmin, deleteSsoProviderDto, async (c) => {
|
||||
const providerId = c.req.param("providerId");
|
||||
const organizationId = c.get("organizationId");
|
||||
|
||||
const deleted = await authService.deleteSsoProvider(providerId, organizationId);
|
||||
|
||||
if (!deleted) {
|
||||
return c.json({ message: "Provider not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
})
|
||||
.patch(
|
||||
"/sso-providers/:providerId/auto-linking",
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
updateSsoProviderAutoLinkingDto,
|
||||
validator("json", updateSsoProviderAutoLinkingBody),
|
||||
async (c) => {
|
||||
const providerId = c.req.param("providerId");
|
||||
const organizationId = c.get("organizationId");
|
||||
const { enabled } = c.req.valid("json");
|
||||
|
||||
const updated = await authService.updateSsoProviderAutoLinking(providerId, organizationId, enabled);
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ message: "Provider not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
},
|
||||
)
|
||||
.delete("/sso-invitations/:invitationId", requireAuth, requireAdmin, deleteSsoInvitationDto, async (c) => {
|
||||
const invitationId = c.req.param("invitationId");
|
||||
const organizationId = c.get("organizationId");
|
||||
|
||||
const invitation = await authService.getSsoInvitationById(invitationId);
|
||||
if (!invitation || invitation.organizationId !== organizationId) {
|
||||
return c.json({ message: "Invitation not found" }, 404);
|
||||
}
|
||||
|
||||
await authService.deleteSsoInvitation(invitationId);
|
||||
|
||||
return c.json({ success: true });
|
||||
})
|
||||
.get("/admin-users", requireAuth, requireAdmin, getAdminUsersDto, async (c) => {
|
||||
const headers = c.req.raw.headers;
|
||||
|
||||
const usersData = await auth.api.listUsers({
|
||||
headers,
|
||||
query: { limit: 100 },
|
||||
});
|
||||
|
||||
const userIds = usersData.users.map((u) => u.id);
|
||||
const accountsByUser = await authService.getUserAccounts(userIds);
|
||||
|
||||
return c.json<AdminUsersDto>({
|
||||
users: usersData.users.map((adminUser) => ({
|
||||
id: adminUser.id,
|
||||
name: adminUser.name,
|
||||
email: adminUser.email,
|
||||
role: adminUser.role ?? "user",
|
||||
banned: Boolean(adminUser.banned),
|
||||
accounts: accountsByUser[adminUser.id] ?? [],
|
||||
})),
|
||||
total: usersData.total,
|
||||
});
|
||||
})
|
||||
.delete("/admin-users/:userId/accounts/:accountId", requireAuth, requireAdmin, deleteUserAccountDto, async (c) => {
|
||||
const userId = c.req.param("userId");
|
||||
const accountId = c.req.param("accountId");
|
||||
const organizationId = c.get("organizationId");
|
||||
|
||||
const result = await authService.deleteUserAccount(userId, accountId, organizationId);
|
||||
|
||||
if (result.forbidden) {
|
||||
return c.json({ message: "User is not a member of this organization" }, 403);
|
||||
}
|
||||
|
||||
if (result.lastAccount) {
|
||||
return c.json({ message: "Cannot delete the last account of a user" }, 409);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
})
|
||||
.get("/deletion-impact/:userId", requireAuth, requireAdmin, getUserDeletionImpactDto, async (c) => {
|
||||
const userId = c.req.param("userId");
|
||||
const impact = await authService.getUserDeletionImpact(userId);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,102 @@ const statusResponseSchema = type({
|
|||
hasUsers: "boolean",
|
||||
});
|
||||
|
||||
export const publicSsoProvidersDto = type({
|
||||
providers: type({
|
||||
providerId: "string",
|
||||
organizationSlug: "string",
|
||||
})
|
||||
.onUndeclaredKey("delete")
|
||||
.array(),
|
||||
});
|
||||
|
||||
export type PublicSsoProvidersDto = typeof publicSsoProvidersDto.infer;
|
||||
|
||||
export const getPublicSsoProvidersDto = describeRoute({
|
||||
description: "Get public SSO providers for the instance",
|
||||
operationId: "getPublicSsoProviders",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of public SSO providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(publicSsoProvidersDto),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ssoSettingsResponse = type({
|
||||
providers: type({
|
||||
providerId: "string",
|
||||
type: "string",
|
||||
issuer: "string",
|
||||
domain: "string",
|
||||
autoLinkMatchingEmails: "boolean",
|
||||
organizationId: "string | null",
|
||||
}).array(),
|
||||
invitations: type({
|
||||
id: "string",
|
||||
email: "string",
|
||||
role: "string",
|
||||
status: "string",
|
||||
expiresAt: "string",
|
||||
}).array(),
|
||||
});
|
||||
|
||||
export type SsoSettingsDto = typeof ssoSettingsResponse.infer;
|
||||
|
||||
export const getSsoSettingsDto = describeRoute({
|
||||
description: "Get SSO providers and invitations for the active organization",
|
||||
operationId: "getSsoSettings",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "SSO settings for the active organization",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ssoSettingsResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const adminUsersResponse = type({
|
||||
users: type({
|
||||
id: "string",
|
||||
name: "string | null",
|
||||
email: "string",
|
||||
role: "string",
|
||||
banned: "boolean",
|
||||
accounts: type({
|
||||
id: "string",
|
||||
providerId: "string",
|
||||
}).array(),
|
||||
}).array(),
|
||||
total: "number",
|
||||
});
|
||||
|
||||
export type AdminUsersDto = typeof adminUsersResponse.infer;
|
||||
|
||||
export const getAdminUsersDto = describeRoute({
|
||||
description: "List admin users for settings management",
|
||||
operationId: "getAdminUsers",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of users with roles and status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(adminUsersResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const getStatusDto = describeRoute({
|
||||
description: "Get authentication system status",
|
||||
operationId: "getStatus",
|
||||
|
|
@ -52,3 +148,72 @@ export const getUserDeletionImpactDto = describeRoute({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteSsoProviderDto = describeRoute({
|
||||
description: "Delete an SSO provider",
|
||||
operationId: "deleteSsoProvider",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "SSO provider deleted successfully",
|
||||
},
|
||||
404: {
|
||||
description: "Provider not found",
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteSsoInvitationDto = describeRoute({
|
||||
description: "Delete an SSO invitation",
|
||||
operationId: "deleteSsoInvitation",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "SSO invitation deleted successfully",
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteUserAccountDto = describeRoute({
|
||||
description: "Delete an account linked to a user",
|
||||
operationId: "deleteUserAccount",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Account deleted successfully",
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
},
|
||||
409: {
|
||||
description: "Cannot delete the last account",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSsoProviderAutoLinkingBody = type({
|
||||
enabled: "boolean",
|
||||
});
|
||||
|
||||
export const updateSsoProviderAutoLinkingDto = describeRoute({
|
||||
description: "Update whether SSO sign-in can auto-link existing accounts by email",
|
||||
operationId: "updateSsoProviderAutoLinking",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "SSO provider auto-linking setting updated successfully",
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
},
|
||||
404: {
|
||||
description: "Provider not found",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ declare module "hono" {
|
|||
interface ContextVariableMap {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
role?: string | null | undefined;
|
||||
|
|
|
|||
|
|
@ -6,23 +6,42 @@ import {
|
|||
volumesTable,
|
||||
repositoriesTable,
|
||||
backupSchedulesTable,
|
||||
ssoProvider,
|
||||
account,
|
||||
invitation,
|
||||
} from "../../db/schema";
|
||||
import { eq, ne, and, count, inArray } from "drizzle-orm";
|
||||
import type { UserDeletionImpactDto } from "./auth.dto";
|
||||
import { isReservedSsoProviderId } from "~/server/lib/auth/utils/sso-provider-id";
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
* Check if any users exist in the system
|
||||
*/
|
||||
async hasUsers(): Promise<boolean> {
|
||||
async hasUsers() {
|
||||
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
||||
return !!user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public SSO providers for the instance
|
||||
*/
|
||||
async getPublicSsoProviders() {
|
||||
const providers = await db
|
||||
.select({
|
||||
providerId: ssoProvider.providerId,
|
||||
organizationSlug: organization.slug,
|
||||
})
|
||||
.from(ssoProvider)
|
||||
.innerJoin(organization, eq(ssoProvider.organizationId, organization.id));
|
||||
|
||||
return { providers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the impact of deleting a user
|
||||
*/
|
||||
async getUserDeletionImpact(userId: string): Promise<UserDeletionImpactDto> {
|
||||
async getUserDeletionImpact(userId: string) {
|
||||
const userMemberships = await db.query.member.findMany({
|
||||
where: {
|
||||
AND: [{ userId: userId }, { role: "owner" }],
|
||||
|
|
@ -77,7 +96,7 @@ export class AuthService {
|
|||
/**
|
||||
* Cleanup organizations where the user was the sole owner
|
||||
*/
|
||||
async cleanupUserOrganizations(userId: string): Promise<void> {
|
||||
async cleanupUserOrganizations(userId: string) {
|
||||
const impact = await this.getUserDeletionImpact(userId);
|
||||
const orgIds = impact.organizations.map((o) => o.id);
|
||||
|
||||
|
|
@ -85,6 +104,137 @@ export class AuthService {
|
|||
await db.delete(organization).where(inArray(organization.id, orgIds));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an SSO provider and its associated accounts
|
||||
*/
|
||||
async deleteSsoProvider(providerId: string, organizationId: string) {
|
||||
return db.transaction(async (tx) => {
|
||||
const provider = await tx.query.ssoProvider.findFirst({
|
||||
where: { AND: [{ providerId }, { organizationId }] },
|
||||
columns: { id: true, providerId: true },
|
||||
});
|
||||
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isReservedSsoProviderId(provider.providerId)) {
|
||||
await tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
await tx.delete(account).where(eq(account.providerId, provider.providerId));
|
||||
await tx.delete(ssoProvider).where(eq(ssoProvider.id, provider.id));
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-provider auto-linking setting for an organization
|
||||
*/
|
||||
async getSsoProviderAutoLinkingSettings(organizationId: string) {
|
||||
const providers = await db.query.ssoProvider.findMany({
|
||||
columns: { providerId: true, autoLinkMatchingEmails: true },
|
||||
where: { organizationId },
|
||||
});
|
||||
|
||||
return Object.fromEntries(providers.map((provider) => [provider.providerId, provider.autoLinkMatchingEmails]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update per-provider auto-linking setting
|
||||
*/
|
||||
async updateSsoProviderAutoLinking(providerId: string, organizationId: string, enabled: boolean) {
|
||||
const existingProvider = await db.query.ssoProvider.findFirst({
|
||||
where: { AND: [{ providerId }, { organizationId }] },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (!existingProvider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(ssoProvider)
|
||||
.set({ autoLinkMatchingEmails: enabled })
|
||||
.where(and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, organizationId)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an SSO invitation by ID
|
||||
*/
|
||||
async getSsoInvitationById(invitationId: string) {
|
||||
return db.query.invitation.findFirst({
|
||||
where: { id: invitationId },
|
||||
columns: { id: true, organizationId: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an invitation
|
||||
*/
|
||||
async deleteSsoInvitation(invitationId: string) {
|
||||
await db.delete(invitation).where(eq(invitation.id, invitationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a member of an organization
|
||||
*/
|
||||
async getUserMembership(userId: string, organizationId: string) {
|
||||
return db.query.member.findFirst({
|
||||
where: { AND: [{ userId }, { organizationId }] },
|
||||
columns: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch accounts for a list of users, keyed by userId
|
||||
*/
|
||||
async getUserAccounts(userIds: string[]) {
|
||||
if (userIds.length === 0) return {};
|
||||
|
||||
const accounts = await db.query.account.findMany({
|
||||
where: { userId: { in: userIds } },
|
||||
columns: { id: true, providerId: true, userId: true },
|
||||
});
|
||||
|
||||
const grouped: Record<string, { id: string; providerId: string }[]> = {};
|
||||
for (const row of accounts) {
|
||||
if (!grouped[row.userId]) {
|
||||
grouped[row.userId] = [];
|
||||
}
|
||||
grouped[row.userId].push({ id: row.id, providerId: row.providerId });
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single account for a user, refusing if it is the last one
|
||||
*/
|
||||
async deleteUserAccount(userId: string, accountId: string, organizationId: string) {
|
||||
const membership = await this.getUserMembership(userId, organizationId);
|
||||
if (!membership) {
|
||||
return { lastAccount: false, forbidden: true };
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const userAccounts = await tx.query.account.findMany({
|
||||
where: { userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (userAccounts.length <= 1) {
|
||||
return { lastAccount: true, forbidden: false };
|
||||
}
|
||||
|
||||
await tx.delete(account).where(and(eq(account.id, accountId), eq(account.userId, userId)));
|
||||
return { lastAccount: false, forbidden: false };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
|
|
|||
234
bun.lock
234
bun.lock
|
|
@ -5,6 +5,7 @@
|
|||
"": {
|
||||
"name": "zerobyte",
|
||||
"dependencies": {
|
||||
"@better-auth/sso": "^1.4.18",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
|
|
@ -114,6 +116,8 @@
|
|||
|
||||
"@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="],
|
||||
|
||||
"@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="],
|
||||
|
||||
"@azure-rest/core-client": ["@azure-rest/core-client@2.5.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A=="],
|
||||
|
||||
"@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="],
|
||||
|
|
@ -122,7 +126,7 @@
|
|||
|
||||
"@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="],
|
||||
|
||||
"@azure/core-http-compat": ["@azure/core-http-compat@2.3.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-client": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0" } }, "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g=="],
|
||||
"@azure/core-http-compat": ["@azure/core-http-compat@2.3.2", "", { "dependencies": { "@azure/abort-controller": "^2.1.2" }, "peerDependencies": { "@azure/core-client": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0" } }, "sha512-Tf6ltdKzOJEgxZeWLCjMxrxbodB/ZeCbzzA1A2qHbhzAjzjHoBVSUeSl/baT/oHAxhc4qdqVaDKnc2+iE932gw=="],
|
||||
|
||||
"@azure/core-lro": ["@azure/core-lro@2.7.2", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" } }, "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw=="],
|
||||
|
||||
|
|
@ -142,11 +146,11 @@
|
|||
|
||||
"@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="],
|
||||
|
||||
"@azure/msal-browser": ["@azure/msal-browser@4.28.1", "", { "dependencies": { "@azure/msal-common": "15.14.1" } }, "sha512-al2u2fTchbClq3L4C1NlqLm+vwKfhYCPtZN2LR/9xJVaQ4Mnrwf5vANvuyPSJHcGvw50UBmhuVmYUAhTEetTpA=="],
|
||||
"@azure/msal-browser": ["@azure/msal-browser@4.29.0", "", { "dependencies": { "@azure/msal-common": "15.15.0" } }, "sha512-/f3eHkSNUTl6DLQHm+bKecjBKcRQxbd/XLx8lvSYp8Nl/HRyPuIPOijt9Dt0sH50/SxOwQ62RnFCmFlGK+bR/w=="],
|
||||
|
||||
"@azure/msal-common": ["@azure/msal-common@15.14.1", "", {}, "sha512-IkzF7Pywt6QKTS0kwdCv/XV8x8JXknZDvSjj/IccooxnP373T5jaadO3FnOrbWo3S0UqkfIDyZNTaQ/oAgRdXw=="],
|
||||
"@azure/msal-common": ["@azure/msal-common@15.15.0", "", {}, "sha512-/n+bN0AKlVa+AOcETkJSKj38+bvFs78BaP4rNtv3MJCmPH0YrHiskMRe74OhyZ5DZjGISlFyxqvf9/4QVEi2tw=="],
|
||||
|
||||
"@azure/msal-node": ["@azure/msal-node@3.8.6", "", { "dependencies": { "@azure/msal-common": "15.14.1", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-XTmhdItcBckcVVTy65Xp+42xG4LX5GK+9AqAsXPXk4IqUNv+LyQo5TMwNjuFYBfAB2GTG9iSQGk+QLc03vhf3w=="],
|
||||
"@azure/msal-node": ["@azure/msal-node@3.8.8", "", { "dependencies": { "@azure/msal-common": "15.15.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-+f1VrJH1iI517t4zgmuhqORja0bL6LDQXfBqkjuMmfTYXTQQnh1EvwwxO3UbKLT05N0obF72SRHFrC1RBDv5Gg=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
|
|
@ -212,6 +216,8 @@
|
|||
|
||||
"@better-auth/core": ["@better-auth/core@1.4.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-uADLHG1jc5BnEJi7f6ijUN5DmPPRSj++7m/G19z3UqA3MVCo4Y4t1MMa4IIxLCqGDFv22drdfxescgW+HnIowA=="],
|
||||
|
||||
"@better-auth/sso": ["@better-auth/sso@1.4.19", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "fast-xml-parser": "^5.2.5", "jose": "^6.1.0", "samlify": "^2.10.1", "zod": "^4.3.5" }, "peerDependencies": { "better-auth": "1.4.19", "better-call": "1.1.8" } }, "sha512-4OVPii6DQgIRm/DqO74Q4tZHx0LlNHXif3qrW7/iDzRO507h/AVAoJ9/U2fGycsPxUtPh1BT3PGHfX09sbF9Bw=="],
|
||||
|
||||
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.19", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.19" } }, "sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
|
||||
|
|
@ -234,57 +240,57 @@
|
|||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||
|
||||
"@faker-js/faker": ["@faker-js/faker@10.3.0", "", {}, "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw=="],
|
||||
|
||||
|
|
@ -596,6 +602,8 @@
|
|||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
|
@ -606,6 +614,8 @@
|
|||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
|
@ -656,55 +666,55 @@
|
|||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||
|
||||
"@scalar/core": ["@scalar/core@0.3.42", "", { "dependencies": { "@scalar/types": "0.6.7" } }, "sha512-RbyooMuG4oQEOhiA/tC+++bkIK1zeYGNxrTzSAgTrTzVlbFKPzw72fs4gX9/eHDo7qVc9FsymIW6qVpWbySzNg=="],
|
||||
|
||||
|
|
@ -876,15 +886,19 @@
|
|||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.2", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg=="],
|
||||
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
|
||||
|
||||
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
|
|
@ -908,6 +922,8 @@
|
|||
|
||||
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
|
||||
|
||||
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||
|
||||
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||
|
|
@ -918,7 +934,7 @@
|
|||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"better-auth": ["better-auth@1.4.19", "", { "dependencies": { "@better-auth/core": "1.4.19", "@better-auth/telemetry": "1.4.19", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-3RlZJcA0+NH25wYD85vpIGwW9oSTuEmLIaGbT8zg41w/Pa2hVWHKedjoUHHJtnzkBXzDb+CShkLnSw7IThDdqQ=="],
|
||||
|
||||
|
|
@ -944,7 +960,9 @@
|
|||
|
||||
"c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001767", "", {}, "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ=="],
|
||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
|
|
@ -978,7 +996,7 @@
|
|||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
|
|
@ -1076,13 +1094,13 @@
|
|||
|
||||
"dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.15-859cf75", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-Y36s1XQGVb1PgU3aRNgufp1K3D2VkIifu8kv4Ubsmxi+Dq+N7KMklnpp7Knu/XC4FZi2MHPPG3v3o097r0/TcQ=="],
|
||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.9-e89174b", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "tsx": "^4.20.6" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-Xrw3k8E2CbSZr+kqe3k5W4oxd2fbEyczjKtyGIkAq0x9Wqpa/VtAT6Mkh83sIzqG4OSN7lOoUafsDxSE/AR7RA=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.9-e89174b", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-B5KR/qYMZ0JMOurK+0xi1ObpOQcgrjaC9wHUiU2eTJjLemuh2CoQIw6yur68NxZG6Xcd0b9qghUNC/78/bEfbg=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="],
|
||||
|
||||
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
|
||||
|
||||
|
|
@ -1092,10 +1110,12 @@
|
|||
|
||||
"es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
|
@ -1118,6 +1138,8 @@
|
|||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.3.7", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
|
@ -1132,7 +1154,7 @@
|
|||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w=="],
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
|
|
@ -1142,7 +1164,7 @@
|
|||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"h3": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
|
||||
"h3": ["h3@2.0.1-rc.14", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.11.2" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-163qbGmTr/9rqQRNuqMqtgXnOUAkE4KTdauiC9y0E5iG1I65kte9NyfWvZw5RTDMt6eY+DtyoNzrQ9wA2BfvGQ=="],
|
||||
|
||||
"h3-v2": ["h3@2.0.1-rc.14", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.11.2" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-163qbGmTr/9rqQRNuqMqtgXnOUAkE4KTdauiC9y0E5iG1I65kte9NyfWvZw5RTDMt6eY+DtyoNzrQ9wA2BfvGQ=="],
|
||||
|
||||
|
|
@ -1206,7 +1228,7 @@
|
|||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="],
|
||||
|
||||
|
|
@ -1316,7 +1338,7 @@
|
|||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
|
|
@ -1428,13 +1450,17 @@
|
|||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="],
|
||||
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
|
||||
|
||||
"ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="],
|
||||
|
||||
|
|
@ -1454,6 +1480,8 @@
|
|||
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.15.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.15.0", "@oxlint-tsgolint/darwin-x64": "0.15.0", "@oxlint-tsgolint/linux-arm64": "0.15.0", "@oxlint-tsgolint/linux-x64": "0.15.0", "@oxlint-tsgolint/win32-arm64": "0.15.0", "@oxlint-tsgolint/win32-x64": "0.15.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-iwvFmhKQVZzVTFygUVI4t2S/VKEm+Mqkw3jQRJwfDuTcUYI5LCIYzdO5Dbuv4mFOkXZCcXaRRh0m+uydB5xdqw=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
|
@ -1546,7 +1574,7 @@
|
|||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||
|
||||
|
|
@ -1556,6 +1584,8 @@
|
|||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"samlify": ["samlify@2.10.2", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.6", "camelcase": "^6.2.0", "node-forge": "^1.3.0", "node-rsa": "^1.1.1", "pako": "^1.0.10", "uuid": "^8.3.2", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.32" } }, "sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
|
@ -1590,6 +1620,8 @@
|
|||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
|
@ -1604,7 +1636,7 @@
|
|||
|
||||
"tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="],
|
||||
|
||||
"tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
|
||||
"tedious": ["tedious@19.2.1", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.5", "@types/node": ">=18", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
|
|
@ -1634,13 +1666,13 @@
|
|||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-fest": ["type-fest@5.4.3", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA=="],
|
||||
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
|
||||
"undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="],
|
||||
"undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
|
|
@ -1686,7 +1718,7 @@
|
|||
|
||||
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
||||
|
||||
"wait-for-expect": ["wait-for-expect@4.0.0", "", {}, "sha512-mcH2HYUUHhdFGHVJkgwkBxRihZO4VSuPyh6xhYHz7LEnYkcaLbTAEEsTpYiFw4UY45XdTZYYIaquuMucw9wWMw=="],
|
||||
|
||||
|
|
@ -1708,8 +1740,16 @@
|
|||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
|
||||
|
||||
"xml-crypto": ["xml-crypto@6.1.2", "", { "dependencies": { "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.10", "xpath": "^0.0.33" } }, "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w=="],
|
||||
|
||||
"xml-escape": ["xml-escape@1.1.0", "", {}, "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg=="],
|
||||
|
||||
"xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="],
|
||||
|
||||
"xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
||||
|
|
@ -1726,8 +1766,6 @@
|
|||
|
||||
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@happy-dom/global-registrator/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
||||
|
||||
"@hey-api/shared/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
|
@ -1738,6 +1776,8 @@
|
|||
|
||||
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
|
|
@ -1750,7 +1790,7 @@
|
|||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"@scalar/types/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
|
||||
|
||||
|
|
@ -1780,41 +1820,29 @@
|
|||
|
||||
"@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@types/mssql/@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"@types/readable-stream/@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"@types/ws/@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"c12/dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"dotenv-cli/dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"h3-v2/srvx": ["srvx@0.11.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-m/2p87bqWZ94xpRN06qNBwh0xq/D0dXajnvPDSHFqrTogxuTWYNP1UHz6Cf+oY7D+NPLY35TJAp4ESIKn0WArQ=="],
|
||||
"h3/srvx": ["srvx@0.11.7", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-p9qj9wkv/MqG1VoJpOsqXv1QcaVcYRk7ifsC6i3TEwDXFyugdhJN4J3KzQPZq2IJJ2ZCt7ASOB++85pEK38jRw=="],
|
||||
|
||||
"happy-dom/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
||||
|
||||
"jsonwebtoken/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
"h3-v2/srvx": ["srvx@0.11.7", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-p9qj9wkv/MqG1VoJpOsqXv1QcaVcYRk7ifsC6i3TEwDXFyugdhJN4J3KzQPZq2IJJ2ZCt7ASOB++85pEK38jRw=="],
|
||||
|
||||
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
||||
|
||||
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
|
||||
"mssql/tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
|
||||
|
||||
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
|
|
@ -1824,29 +1852,15 @@
|
|||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"tedious/@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="],
|
||||
|
||||
"@azure/identity/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"@types/mssql/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"@types/readable-stream/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"happy-dom/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"tedious/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
"mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,13 +61,25 @@ services:
|
|||
- DISABLE_RATE_LIMITING=true
|
||||
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
||||
- BASE_URL=http://localhost:4096
|
||||
- TRUSTED_ORIGINS=http://dex:5557,http://localhost:5557
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
ports:
|
||||
- "4096:4096"
|
||||
depends_on:
|
||||
- dex
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ./playwright/data:/var/lib/zerobyte/data
|
||||
- ./playwright/temp:/test-data
|
||||
|
||||
dex:
|
||||
image: ghcr.io/dexidp/dex:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5557:5557"
|
||||
volumes:
|
||||
- ./playwright/dex-config.yaml:/etc/dex/cfg.yaml:ro
|
||||
command: dex serve /etc/dex/cfg.yaml
|
||||
|
|
|
|||
|
|
@ -1,85 +1,115 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||||
import { gotoAndWaitForAppReady } from "./helpers/page";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
const testDataPath = path.join(process.cwd(), "playwright", "temp");
|
||||
|
||||
test.beforeAll(() => {
|
||||
const testDataPath = path.join(process.cwd(), "playwright", "temp");
|
||||
if (fs.existsSync(testDataPath)) {
|
||||
for (const file of fs.readdirSync(testDataPath)) {
|
||||
fs.rmSync(path.join(testDataPath, file), { recursive: true, force: true });
|
||||
}
|
||||
} else {
|
||||
fs.mkdirSync(testDataPath, { recursive: true });
|
||||
}
|
||||
});
|
||||
type ScenarioNames = {
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
backupName: string;
|
||||
};
|
||||
|
||||
test("can backup & restore a file", async ({ page }) => {
|
||||
await gotoAndWaitForAppReady(page, "/");
|
||||
await expect(page).toHaveURL("/volumes");
|
||||
function getRunId(testInfo: TestInfo) {
|
||||
return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// 0. Create a test file in /test-data
|
||||
const testDataPath = path.join(process.cwd(), "playwright", "temp");
|
||||
const filePath = path.join(testDataPath, "test.json");
|
||||
function getScenarioNames(runId: string): ScenarioNames {
|
||||
return {
|
||||
volumeName: `Volume-${runId}`,
|
||||
repositoryName: `Repo-${runId}`,
|
||||
backupName: `Backup-${runId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareTestFile(runId: string): string {
|
||||
const runPath = path.join(testDataPath, runId);
|
||||
fs.mkdirSync(runPath, { recursive: true });
|
||||
|
||||
const filePath = path.join(runPath, "test.json");
|
||||
fs.writeFileSync(filePath, JSON.stringify({ data: "test file" }));
|
||||
|
||||
// 1. Create a local volume on /test-data
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function createBackupScenario(page: Page, names: ScenarioNames) {
|
||||
await page.getByRole("button", { name: "Create Volume" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("Test Volume");
|
||||
await page.getByRole("textbox", { name: "Name" }).fill(names.volumeName);
|
||||
await page.getByRole("button", { name: "test-data" }).click();
|
||||
await page.getByRole("button", { name: "Create Volume" }).click();
|
||||
await expect(page.getByText("Volume created successfully")).toBeVisible();
|
||||
|
||||
// 2. Create a local repository on the default location
|
||||
await page.getByRole("link", { name: "Repositories" }).click();
|
||||
await page.getByRole("button", { name: "Create repository" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("Test Repo");
|
||||
await page.getByRole("textbox", { name: "Name" }).fill(names.repositoryName);
|
||||
await page.getByRole("combobox", { name: "Backend" }).click();
|
||||
await page.getByRole("option", { name: "Local" }).click();
|
||||
await page.getByRole("button", { name: "Create repository" }).click();
|
||||
await expect(page.getByText("Repository created successfully")).toBeVisible();
|
||||
await expect(page.getByText("Repository created successfully")).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// 3. Create a backup schedule
|
||||
await page.getByRole("link", { name: "Backups" }).click();
|
||||
await page.getByRole("button", { name: "Create a backup job" }).click();
|
||||
const createBackupButton = page.getByRole("button", { name: "Create a backup job" }).first();
|
||||
if (await createBackupButton.isVisible()) {
|
||||
await createBackupButton.click();
|
||||
} else {
|
||||
await page.getByRole("link", { name: "Create a backup job" }).first().click();
|
||||
}
|
||||
await page.getByRole("combobox").filter({ hasText: "Choose a volume to backup" }).click();
|
||||
await page.getByRole("option", { name: "Test Volume" }).click();
|
||||
await page.getByRole("textbox", { name: "Backup name" }).fill("Test Backup");
|
||||
await page.getByRole("option", { name: names.volumeName }).click();
|
||||
await page.getByRole("textbox", { name: "Backup name" }).fill(names.backupName);
|
||||
await page.getByRole("combobox").filter({ hasText: "Select a repository" }).click();
|
||||
await page.getByRole("option", { name: "Test Repo" }).click();
|
||||
await page.getByRole("option", { name: names.repositoryName }).click();
|
||||
await page.getByRole("combobox").filter({ hasText: "Select frequency" }).click();
|
||||
await page.getByRole("option", { name: "Daily" }).click();
|
||||
await page.getByRole("textbox", { name: "Execution time" }).fill("00:00");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByText("Backup job created successfully")).toBeVisible();
|
||||
}
|
||||
|
||||
test("can backup & restore a file", async ({ page }, testInfo) => {
|
||||
const runId = getRunId(testInfo);
|
||||
const names = getScenarioNames(runId);
|
||||
const filePath = prepareTestFile(runId);
|
||||
|
||||
await gotoAndWaitForAppReady(page, "/");
|
||||
await expect(page).toHaveURL("/volumes");
|
||||
|
||||
await createBackupScenario(page, names);
|
||||
|
||||
// 4. Runs that schedule once
|
||||
await page.getByRole("button", { name: "Backup now" }).click();
|
||||
await expect(page.getByText("Backup started successfully")).toBeVisible();
|
||||
await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// 5. Modify the json file after the backup
|
||||
fs.writeFileSync(filePath, JSON.stringify({ data: "modified file" }));
|
||||
|
||||
// 6. Restores the file from backup
|
||||
await page.getByRole("button", { name: /20 B/ }).click();
|
||||
await page
|
||||
.getByRole("button", { name: /\d+ B$/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole("link", { name: "Restore" }).click();
|
||||
await expect(page).toHaveURL(/\/restore/);
|
||||
await page.getByRole("button", { name: "Restore All" }).click();
|
||||
await expect(page.getByText("Restore completed")).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// 7. Ensures that the file is back to its previous state
|
||||
const restoredContent = fs.readFileSync(filePath, "utf8");
|
||||
expect(JSON.parse(restoredContent)).toEqual({ data: "test file" });
|
||||
});
|
||||
|
||||
test("deleting a volume cascades and removes its backup schedule", async ({ page }) => {
|
||||
await gotoAndWaitForAppReady(page, "/backups");
|
||||
await page.getByText("Test Backup", { exact: true }).first().click();
|
||||
test("deleting a volume cascades and removes its backup schedule", async ({ page }, testInfo) => {
|
||||
const runId = getRunId(testInfo);
|
||||
const names = getScenarioNames(runId);
|
||||
|
||||
const volumeLink = page.locator("main").getByRole("link", { name: "Test Volume", exact: true }).first();
|
||||
await gotoAndWaitForAppReady(page, "/");
|
||||
await expect(page).toHaveURL("/volumes");
|
||||
|
||||
await createBackupScenario(page, names);
|
||||
|
||||
await gotoAndWaitForAppReady(page, "/backups");
|
||||
await page.getByText(names.backupName, { exact: true }).first().click();
|
||||
|
||||
const volumeLink = page.locator("main").getByRole("link", { name: names.volumeName, exact: true }).first();
|
||||
await expect(volumeLink).toBeVisible();
|
||||
await volumeLink.click();
|
||||
await expect(page).toHaveURL(/\/volumes\/[^/?#]+/);
|
||||
|
|
@ -95,5 +125,5 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page
|
|||
await expect(page.getByText("Volume deleted successfully")).toBeVisible();
|
||||
|
||||
await gotoAndWaitForAppReady(page, "/backups");
|
||||
await expect(page.getByText("Test Backup", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(names.backupName, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
|
|
|||
167
e2e/0003-oidc.spec.ts
Normal file
167
e2e/0003-oidc.spec.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
import { gotoAndWaitForAppReady, waitForAppReady } from "./helpers/page";
|
||||
|
||||
const dexOrigin = process.env.E2E_DEX_ORIGIN ?? "http://dex:5557";
|
||||
const issuer = `${dexOrigin}/dex`;
|
||||
const discoveryEndpoint = `${issuer}/.well-known/openid-configuration`;
|
||||
const appBaseUrl = `http://${process.env.SERVER_IP ?? "localhost"}:4096`;
|
||||
|
||||
const providerIds = {
|
||||
register: "test-oidc-register",
|
||||
uninvited: "test-oidc-uninvited",
|
||||
invited: "test-oidc-invited",
|
||||
autoLink: "test-oidc",
|
||||
} as const;
|
||||
|
||||
const dexPassword = "password";
|
||||
const uninvitedUserEmail = "admin@example.com";
|
||||
const invitedUserEmail = "user@example.com";
|
||||
const autoLinkTargetEmail = "test@example.com";
|
||||
const autoLinkTargetUsername = "sso-link-target";
|
||||
const inviteOnlyMessage =
|
||||
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
||||
|
||||
async function openSsoSettings(page: Page) {
|
||||
await gotoAndWaitForAppReady(page, "/settings?tab=users");
|
||||
await expect(page.getByText("Single Sign-On")).toBeVisible();
|
||||
}
|
||||
|
||||
async function registerOidcProvider(page: Page, providerId: string) {
|
||||
await gotoAndWaitForAppReady(page, "/settings/sso/new");
|
||||
|
||||
await page.getByRole("textbox", { name: "Provider ID" }).fill(providerId);
|
||||
await page.getByRole("textbox", { name: "Organization Domain" }).fill("example.com");
|
||||
await page.getByRole("textbox", { name: "Issuer URL" }).fill(issuer);
|
||||
await page.getByRole("textbox", { name: "Discovery Endpoint" }).fill(discoveryEndpoint);
|
||||
await page.getByRole("textbox", { name: "Client ID" }).fill("zerobyte-test");
|
||||
await page.getByRole("textbox", { name: "Client Secret" }).fill("test-secret-12345");
|
||||
await page.getByRole("button", { name: "Register Provider" }).click();
|
||||
|
||||
await expect(page.getByText("SSO provider registered successfully")).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: providerId, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function createPendingInvitation(page: Page, email: string) {
|
||||
const response = await page.request.post("/api/auth/organization/invite-member", {
|
||||
headers: {
|
||||
Origin: appBaseUrl,
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
role: "member",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to invite ${email}: ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAutoLinkTargetUser(page: Page, email: string, username: string) {
|
||||
const response = await page.request.post("/api/auth/admin/create-user", {
|
||||
headers: {
|
||||
Origin: appBaseUrl,
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
password: dexPassword,
|
||||
name: "SSO Link Target",
|
||||
role: "user",
|
||||
data: {
|
||||
username,
|
||||
hasDownloadedResticPassword: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to create auto-link target user: ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function setProviderAutoLinking(page: Page, providerId: string, enabled: boolean) {
|
||||
await openSsoSettings(page);
|
||||
const providerRow = page
|
||||
.getByRole("row")
|
||||
.filter({ has: page.getByRole("cell", { name: providerId, exact: true }) })
|
||||
.first();
|
||||
const autoLinkSwitch = providerRow.getByRole("switch");
|
||||
const expectedState = enabled ? "true" : "false";
|
||||
const currentState = await autoLinkSwitch.getAttribute("aria-checked");
|
||||
const expectedToast = enabled ? "Automatic account linking enabled" : "Automatic account linking disabled";
|
||||
|
||||
if (currentState !== expectedState) {
|
||||
await autoLinkSwitch.click();
|
||||
await expect(page.getByText(expectedToast)).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(autoLinkSwitch).toHaveAttribute("aria-checked", expectedState);
|
||||
}
|
||||
|
||||
async function withOidcLoginAttempt(
|
||||
browser: Browser,
|
||||
providerId: string,
|
||||
dexLogin: string,
|
||||
assertions: (page: Page) => Promise<void>,
|
||||
) {
|
||||
const context = await browser.newContext({
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [],
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await gotoAndWaitForAppReady(page, `${appBaseUrl}/login`);
|
||||
await page.getByRole("button", { name: `Log in with ${providerId}`, exact: true }).click();
|
||||
await page.waitForURL(/\/dex\/auth/, { timeout: 60000 });
|
||||
|
||||
await page.locator('input[name="login"]').fill(dexLogin);
|
||||
await page.locator('input[name="password"]').fill(dexPassword);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await assertions(page);
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
test("admin can register an OIDC provider", async ({ page }) => {
|
||||
await registerOidcProvider(page, providerIds.register);
|
||||
});
|
||||
|
||||
test("uninvited OIDC users are blocked", async ({ page, browser }) => {
|
||||
await registerOidcProvider(page, providerIds.uninvited);
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.uninvited, uninvitedUserEmail, async (ssoPage) => {
|
||||
await ssoPage.waitForURL(/\/login(\/error)?/, { timeout: 60000 });
|
||||
await waitForAppReady(ssoPage);
|
||||
await expect(ssoPage.getByText(inviteOnlyMessage)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("invited OIDC users can sign in", async ({ page, browser }) => {
|
||||
await registerOidcProvider(page, providerIds.invited);
|
||||
await createPendingInvitation(page, invitedUserEmail);
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.invited, invitedUserEmail, async (ssoPage) => {
|
||||
await ssoPage.waitForURL(/\/volumes/, { timeout: 60000 });
|
||||
await waitForAppReady(ssoPage);
|
||||
await expect(ssoPage).toHaveURL(/\/volumes/);
|
||||
});
|
||||
});
|
||||
|
||||
test("auto-link setting can be enabled for an OIDC provider", async ({ page, browser }) => {
|
||||
await registerOidcProvider(page, providerIds.autoLink);
|
||||
await createAutoLinkTargetUser(page, autoLinkTargetEmail, autoLinkTargetUsername);
|
||||
await createPendingInvitation(page, autoLinkTargetEmail);
|
||||
|
||||
await setProviderAutoLinking(page, providerIds.autoLink, true);
|
||||
|
||||
await withOidcLoginAttempt(browser, providerIds.autoLink, autoLinkTargetEmail, async (ssoPage) => {
|
||||
await ssoPage.waitForURL(/\/volumes/, { timeout: 60000 });
|
||||
await waitForAppReady(ssoPage);
|
||||
await expect(ssoPage).toHaveURL(/\/volumes/);
|
||||
});
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"tsc": "tsc --noEmit",
|
||||
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
|
||||
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
|
||||
"start:e2e": "docker compose down && docker compose up --build zerobyte-e2e",
|
||||
"start:e2e": "docker compose down && rm -f playwright/data/zerobyte.db playwright/data/cache.db playwright/data/restic.pass playwright/.auth/user.json playwright/restic.pass && docker compose up --build zerobyte-e2e",
|
||||
"start:distroless": "docker compose down && docker compose up --build zerobyte-distroless",
|
||||
"gen:api-client": "dotenv -e .env.local -- openapi-ts",
|
||||
"gen:migrations": "dotenv -e .env.local -- drizzle-kit generate",
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
"test:codegen": "playwright codegen localhost:4096"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/sso": "^1.4.18",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -36,6 +37,7 @@
|
|||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ export default defineConfig({
|
|||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
timeout: 90000,
|
||||
retries: 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: `http://${process.env.SERVER_IP}:4096`,
|
||||
|
|
@ -17,12 +17,16 @@ export default defineConfig({
|
|||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
workers: 1,
|
||||
},
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: "playwright/.auth/user.json",
|
||||
launchOptions: {
|
||||
args: ["--host-rules=MAP dex 127.0.0.1"],
|
||||
},
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
|
|
|
|||
50
playwright/dex-config.yaml
Normal file
50
playwright/dex-config.yaml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
issuer: http://dex:5557/dex
|
||||
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: /tmp/dex.db
|
||||
|
||||
web:
|
||||
http: 0.0.0.0:5557
|
||||
|
||||
enablePasswordDB: true
|
||||
|
||||
staticPasswords:
|
||||
- email: "admin@example.com"
|
||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||
username: "admin"
|
||||
- email: "user@example.com"
|
||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||
username: "user"
|
||||
- email: "test@example.com"
|
||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||
username: "test"
|
||||
- email: "test@test.com"
|
||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||
username: "test-local"
|
||||
|
||||
staticClients:
|
||||
- id: zerobyte-test
|
||||
name: Zerobyte Test
|
||||
redirectURIs:
|
||||
- "http://localhost:3000/api/auth/sso/callback/credential"
|
||||
- "http://localhost:3000/api/auth/sso/callback/dex"
|
||||
- "http://localhost:4096/api/auth/sso/callback/test-oidc"
|
||||
- "http://localhost:4096/api/auth/sso/callback/test-oidc-register"
|
||||
- "http://localhost:4096/api/auth/sso/callback/test-oidc-uninvited"
|
||||
- "http://localhost:4096/api/auth/sso/callback/test-oidc-invited"
|
||||
- "http://localhost:4096/api/auth/sso/callback/test-oidc-autolink"
|
||||
secret: test-secret-12345
|
||||
|
||||
oauth2:
|
||||
skipApprovalScreen: true
|
||||
|
||||
logger:
|
||||
level: debug
|
||||
format: json
|
||||
|
||||
# Issuer URL: http://dex:5557/dex
|
||||
# Discovery URL: http://dex:5557/dex/.well-known/openid-configuration
|
||||
# Client ID: zerobyte-test
|
||||
# Client Secret: test-secret-12345
|
||||
Loading…
Reference in a new issue