Compare commits

...

1 commit

Author SHA1 Message Date
Nicolas Meienberger
ce8e56fe29 wip 2026-01-17 12:33:16 +01:00
16 changed files with 693 additions and 72 deletions

View file

@ -28,8 +28,10 @@ const Alert = React.forwardRef<
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props}>{props.children}</h5>
({ className, children, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props}>
{children}
</h5>
),
);
AlertTitle.displayName = "AlertTitle";

View file

@ -1,8 +1,17 @@
import { createAuthClient } from "better-auth/react";
import { twoFactorClient, usernameClient } from "better-auth/client/plugins";
import { organizationClient, twoFactorClient, usernameClient } from "better-auth/client/plugins";
import { inferAdditionalFields } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import { adminClient } from "better-auth/client/plugins";
import type { auth } from "~/lib/auth";
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>(), usernameClient(), twoFactorClient()],
plugins: [
inferAdditionalFields<typeof auth>(),
usernameClient(),
adminClient(),
organizationClient(),
twoFactorClient(),
ssoClient(),
],
});

View file

@ -0,0 +1,36 @@
import { AlertCircle } from "lucide-react";
import { Link, useSearchParams } from "react-router";
import { AuthLayout } from "~/client/components/auth-layout";
import { Button } from "~/client/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import type { Route } from "./+types/auth-error";
export function meta(_: Route.MetaArgs) {
return [{ title: "Authentication Error - Zerobyte" }];
}
export default function AuthErrorPage() {
const [searchParams] = useSearchParams();
const errorMessage = searchParams.get("error") || "An unknown error occurred";
const formattedError = errorMessage
.split(/[_-]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
return (
<AuthLayout title="Authentication Error" description="An error occurred during authentication">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{formattedError}</AlertDescription>
</Alert>
<div className="flex flex-col gap-2">
<Button>
<Link to="/login">Back to Login</Link>
</Button>
</div>
</AuthLayout>
);
}

View file

@ -14,6 +14,7 @@ import { authClient } from "~/client/lib/auth-client";
import { authMiddleware } from "~/middleware/auth";
import { ResetPasswordDialog } from "../components/reset-password-dialog";
import type { Route } from "./+types/login";
import { listSsoProviders } from "~/client/api-client";
export const clientMiddleware = [authMiddleware];
@ -34,7 +35,14 @@ const loginSchema = type({
type LoginFormValues = typeof loginSchema.inferIn;
export default function LoginPage() {
export async function clientLoader(_: Route.ClientLoaderArgs) {
const providers = await listSsoProviders();
return { providers: providers.data ?? [] };
}
export default function LoginPage({ loaderData }: Route.ComponentProps) {
const { providers } = loaderData;
const navigate = useNavigate();
const [showResetDialog, setShowResetDialog] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
@ -128,6 +136,13 @@ export default function LoginPage() {
form.reset();
};
const handleSSOLogin = async (providerId: string) => {
await authClient.signIn.sso({
providerId,
callbackURL: "/volumes",
});
};
if (requires2FA) {
return (
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
@ -240,6 +255,26 @@ export default function LoginPage() {
</form>
</Form>
{providers && providers.length > 0 && (
<>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<div className="grid gap-2">
{providers.map((p) => (
<Button key={p.id} variant="outline" className="w-full" onClick={() => handleSSOLogin(p.providerId)}>
Sign in with {p.providerId}
</Button>
))}
</div>
</>
)}
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout>
);

View file

@ -0,0 +1,237 @@
import { Plus, Shield, Link as LinkIcon, Link2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
interface SSOProvider {
id: string;
providerId: string;
issuer: string;
domain: string;
}
interface SSOSectionProps {
providers: SSOProvider[];
userAccounts: { providerId: string; userId: string }[];
}
export function SSOSection({ providers, userAccounts }: SSOSectionProps) {
const [isRegistering, setIsRegistering] = useState(false);
const [open, setOpen] = useState(false);
const [providerId, setProviderId] = useState("");
const [issuer, setIssuer] = useState("");
const [domain, setDomain] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
await authClient.sso.register({
providerId,
issuer,
domain,
oidcConfig: {
clientId,
clientSecret,
},
fetchOptions: {
onRequest: () => {
setIsRegistering(true);
},
onResponse: () => {
setIsRegistering(false);
},
onError: async ({ error }) => {
toast.error("Failed to register SSO provider", { description: error.message });
},
onSuccess: async () => {
toast.success("SSO provider registered successfully");
window.location.reload();
},
},
});
};
const callbackUrl = `${window.location.origin}/api/auth/sso/callback/${providerId || ":providerId"}`;
const handleLink = async (providerId: string) => {
await authClient.signIn.sso({
providerId,
callbackURL: window.location.href,
fetchOptions: {
onError: async ({ error }) => {
toast.error("Failed to link SSO provider", { description: error.message });
},
onSuccess: async () => {
toast.success("SSO provider linked successfully");
window.location.reload();
},
},
});
};
const handleUnlink = async (providerId: string) => {
await authClient.unlinkAccount({
providerId,
fetchOptions: {
onError: async ({ error }) => {
toast.error("Failed to unlink SSO provider", { description: error.message });
},
onSuccess: async () => {
toast.success("SSO provider unlinked successfully");
window.location.reload();
},
},
});
};
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" />
SSO Providers
</CardTitle>
<CardDescription className="mt-1.5">Manage Single Sign-On providers for your instance</CardDescription>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="size-4 mr-2" />
Add Provider
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleRegister}>
<DialogHeader>
<DialogTitle>Add SSO Provider</DialogTitle>
<DialogDescription>Enter the details of your OIDC provider.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="providerId">Provider ID</Label>
<Input
id="providerId"
value={providerId}
onChange={(e) => setProviderId(e.target.value)}
placeholder="e.g. google, okta-work"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="issuer">Issuer URL</Label>
<Input
id="issuer"
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
placeholder="https://accounts.google.com"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="domain">Domain</Label>
<Input
id="domain"
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="example.com"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="clientId">Client ID</Label>
<Input id="clientId" value={clientId} onChange={(e) => setClientId(e.target.value)} required />
</div>
<div className="grid gap-2">
<Label htmlFor="clientSecret">Client Secret</Label>
<Input
id="clientSecret"
type="password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
required
/>
</div>
<div className="grid gap-2 mt-2">
<Label className="text-xs text-muted-foreground uppercase">Callback URL</Label>
<div className="p-2 bg-muted rounded text-xs break-all font-mono">{callbackUrl}</div>
<p className="text-[10px] text-muted-foreground italic">
Copy this URL to your Identity Provider configuration.
</p>
</div>
</div>
<DialogFooter>
<Button type="submit" loading={isRegistering}>
Register Provider
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<CardContent className="p-6">
{providers.length === 0 ? (
<p className="text-sm text-muted-foreground">No SSO providers configured.</p>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Provider ID</TableHead>
<TableHead>Domain</TableHead>
<TableHead>User id</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{providers.map((p) => {
const linkedAccount = userAccounts.find((ua) => ua.providerId === p.providerId);
return (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.providerId}</TableCell>
<TableCell>{p.domain}</TableCell>
<TableCell>{linkedAccount ? linkedAccount.userId : "-"}</TableCell>
<TableCell className="text-right">
{linkedAccount ? (
<Button variant="destructive" size="sm" onClick={() => handleUnlink(p.providerId)}>
{<Link2 className="size-4 mr-2" />}
Unlink your account
</Button>
) : (
<Button variant="ghost" size="sm" onClick={() => handleLink(p.providerId)}>
<LinkIcon className="size-4 mr-2" />
Link your account
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</>
);
}

View file

@ -20,7 +20,9 @@ import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { appContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { SSOSection } from "../components/sso-section";
import type { Route } from "./+types/settings";
import { listSsoProviders } from "~/client/api-client";
export const handle = {
breadcrumb: () => [{ label: "Settings" }],
@ -36,12 +38,16 @@ export function meta(_: Route.MetaArgs) {
];
}
export async function clientLoader({ context }: Route.LoaderArgs) {
export async function clientLoader({ context }: Route.ClientLoaderArgs) {
const ctx = context.get(appContext);
return ctx;
const providers = await listSsoProviders();
const accounts = await authClient.listAccounts();
return { ...ctx, ssoProviders: providers.data ?? [], userAccounts: accounts.data ?? [] };
}
export default function Settings({ loaderData }: Route.ComponentProps) {
const { user } = loaderData;
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@ -155,7 +161,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label>Username</Label>
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
<Input value={user?.username} disabled className="max-w-md" />
</div>
{/* <div className="space-y-2"> */}
{/* <Label>Email</Label> */}
@ -278,8 +284,8 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
</DialogContent>
</Dialog>
</CardContent>
<TwoFactorSection twoFactorEnabled={loaderData.user?.twoFactorEnabled} />
<TwoFactorSection twoFactorEnabled={user?.twoFactorEnabled} />
<SSOSection providers={loaderData.ssoProviders} userAccounts={loaderData.userAccounts} />
</Card>
);
}

View file

@ -32,6 +32,7 @@ export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext)
name: legacyUser.name,
hasDownloadedResticPassword: legacyUser.hasDownloadedResticPassword,
emailVerified: false,
role: "admin",
});
await tx.insert(account).values({

View file

@ -6,11 +6,11 @@ import {
type MiddlewareOptions,
} from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware, twoFactor, username } from "better-auth/plugins";
import { createAuthMiddleware, twoFactor, username, admin, organization } from "better-auth/plugins";
import { sso } from "@better-auth/sso";
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
import { cryptoUtils } from "~/server/utils/crypto";
import { db } from "~/server/db/db";
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
import { config } from "~/server/core/config";
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
@ -21,10 +21,25 @@ const createBetterAuth = (secret: string) =>
trustedOrigins: config.trustedOrigins ?? ["*"],
hooks: {
before: createAuthMiddleware(async (ctx) => {
await ensureOnlyOneUser(ctx);
await convertLegacyUserOnFirstLogin(ctx);
}),
},
databaseHooks: {
user: {
create: {
before: async (user) => {
const anyUser = await db.query.usersTable.findFirst();
const isFirstUser = !anyUser;
if (isFirstUser) {
user.role = "admin";
}
return { data: user };
},
},
},
},
database: drizzleAdapter(db, {
provider: "sqlite",
}),
@ -50,13 +65,22 @@ const createBetterAuth = (secret: string) =>
},
plugins: [
username(),
admin({ defaultRole: "user" }),
organization(),
twoFactor({
backupCodeOptions: {
storeBackupCodes: "encrypted",
amount: 5,
},
}),
sso(),
],
account: {
accountLinking: {
enabled: true,
allowDifferentEmails: true,
},
},
});
type Auth = ReturnType<typeof createBetterAuth>;

View file

@ -4,6 +4,7 @@ export default [
route("onboarding", "./client/modules/auth/routes/onboarding.tsx"),
route("login", "./client/modules/auth/routes/login.tsx"),
route("download-recovery-key", "./client/modules/auth/routes/download-recovery-key.tsx"),
route("auth/error", "./client/modules/auth/routes/auth-error.tsx"),
layout("./client/components/layout.tsx", [
route("/", "./client/routes/root.tsx"),
route("volumes", "./client/modules/volumes/routes/volumes.tsx"),

View file

@ -57,6 +57,10 @@ export const usersTable = sqliteTable("users_table", {
image: text("image"),
displayUsername: text("display_username"),
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).notNull().default(false),
role: text("role"),
banned: integer("banned", { mode: "boolean" }).default(false),
banReason: text("ban_reason"),
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
});
export type User = typeof usersTable.$inferSelect;
@ -78,6 +82,7 @@ export const sessionsTable = sqliteTable(
.default(sql`(unixepoch() * 1000)`),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
impersonatedBy: text("impersonated_by"),
},
(table) => [index("sessionsTable_userId_idx").on(table.userId)],
);
@ -132,26 +137,6 @@ export const verification = sqliteTable(
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const userRelations = relations(usersTable, ({ many }) => ({
sessions: many(sessionsTable),
accounts: many(account),
twoFactors: many(twoFactor),
}));
export const sessionRelations = relations(sessionsTable, ({ one }) => ({
user: one(usersTable, {
fields: [sessionsTable.userId],
references: [usersTable.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(usersTable, {
fields: [account.userId],
references: [usersTable.id],
}),
}));
/**
* Repositories Table
*/
@ -223,18 +208,6 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
});
export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert;
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
volume: one(volumesTable, {
fields: [backupSchedulesTable.volumeId],
references: [volumesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupSchedulesTable.repositoryId],
references: [repositoriesTable.id],
}),
notifications: many(backupScheduleNotificationsTable),
mirrors: many(backupScheduleMirrorsTable),
}));
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
/**
@ -253,9 +226,6 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
.notNull()
.default(sql`(unixepoch() * 1000)`),
});
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
schedules: many(backupScheduleNotificationsTable),
}));
export type NotificationDestination = typeof notificationDestinationsTable.$inferSelect;
/**
@ -280,16 +250,6 @@ export const backupScheduleNotificationsTable = sqliteTable(
},
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
);
export const backupScheduleNotificationRelations = relations(backupScheduleNotificationsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleNotificationsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
destination: one(notificationDestinationsTable, {
fields: [backupScheduleNotificationsTable.destinationId],
references: [notificationDestinationsTable.id],
}),
}));
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
/**
@ -317,16 +277,6 @@ export const backupScheduleMirrorsTable = sqliteTable(
(table) => [unique().on(table.scheduleId, table.repositoryId)],
);
export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleMirrorsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupScheduleMirrorsTable.repositoryId],
references: [repositoriesTable.id],
}),
}));
export type BackupScheduleMirror = typeof backupScheduleMirrorsTable.$inferSelect;
/**
@ -357,3 +307,83 @@ export const twoFactor = sqliteTable(
},
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
);
export const ssoProvider = sqliteTable("sso_provider", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
userId: text("user_id").references(() => usersTable.id, {
onDelete: "cascade",
}),
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
});
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
schedules: many(backupScheduleNotificationsTable),
}));
export const backupScheduleNotificationRelations = relations(backupScheduleNotificationsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleNotificationsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
destination: one(notificationDestinationsTable, {
fields: [backupScheduleNotificationsTable.destinationId],
references: [notificationDestinationsTable.id],
}),
}));
export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleMirrorsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupScheduleMirrorsTable.repositoryId],
references: [repositoriesTable.id],
}),
}));
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
volume: one(volumesTable, {
fields: [backupSchedulesTable.volumeId],
references: [volumesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupSchedulesTable.repositoryId],
references: [repositoriesTable.id],
}),
notifications: many(backupScheduleNotificationsTable),
mirrors: many(backupScheduleMirrorsTable),
}));
export const userRelations = relations(usersTable, ({ many }) => ({
sessions: many(sessionsTable),
accounts: many(account),
twoFactors: many(twoFactor),
ssoProviders: many(ssoProvider),
}));
export const sessionRelations = relations(sessionsTable, ({ one }) => ({
user: one(usersTable, {
fields: [sessionsTable.userId],
references: [usersTable.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(usersTable, {
fields: [account.userId],
references: [usersTable.id],
}),
}));
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
usersTable: one(usersTable, {
fields: [ssoProvider.userId],
references: [usersTable.id],
}),
}));

View file

@ -26,6 +26,11 @@ export const requireAuth = createMiddleware(async (c, next) => {
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
}
if (user.role !== "admin") {
// For now we only have admin users
return c.json<unknown>({ message: "Insufficient permissions" }, 403);
}
c.set("user", user);
await next();

View file

@ -1,9 +1,11 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
deleteSSOProviderDto,
downloadResticPasswordBodySchema,
downloadResticPasswordDto,
getUpdatesDto,
listSSOProvidersDto,
systemInfoDto,
type SystemInfoDto,
type UpdateInfoDto,
@ -12,11 +14,15 @@ import { systemService } from "./system.service";
import { requireAuth } from "../auth/auth.middleware";
import { RESTIC_PASS_FILE } from "../../core/constants";
import { db } from "../../db/db";
import { usersTable } from "../../db/schema";
import { ssoProvider, usersTable } from "../../db/schema";
import { eq } from "drizzle-orm";
import { verifyUserPassword } from "../auth/helpers";
export const systemController = new Hono()
.get("/sso-providers", listSSOProvidersDto, async (c) => {
const providers = await db.select().from(ssoProvider);
return c.json(providers, 200);
})
.use(requireAuth)
.get("/info", systemInfoDto, async (c) => {
const info = await systemService.getSystemInfo();
@ -55,4 +61,9 @@ export const systemController = new Hono()
return c.json({ message: "Failed to read Restic password file" }, 500);
}
},
);
)
.delete("/sso-providers/:id", deleteSSOProviderDto, async (c) => {
const id = c.req.param("id");
await db.delete(ssoProvider).where(eq(ssoProvider.id, id));
return c.json({ message: "Provider deleted" }, 200);
});

View file

@ -79,3 +79,39 @@ export const downloadResticPasswordDto = describeRoute({
},
},
});
export const ssoProviderSchema = type({
id: "string",
providerId: "string",
issuer: "string",
domain: "string",
});
export const listSSOProvidersResponse = ssoProviderSchema.array();
export const listSSOProvidersDto = describeRoute({
description: "List all configured SSO providers",
tags: ["Auth"],
operationId: "listSSOProviders",
responses: {
200: {
description: "List of SSO providers",
content: {
"application/json": {
schema: resolver(listSSOProvidersResponse),
},
},
},
},
});
export const deleteSSOProviderDto = describeRoute({
description: "Delete an SSO provider",
tags: ["Auth"],
operationId: "deleteSSOProvider",
responses: {
200: {
description: "Provider deleted",
},
},
});

148
auth-schema.ts Normal file
View file

@ -0,0 +1,148 @@
import { relations } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const usersTable = sqliteTable("users_table", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }).default(false).notNull(),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => new Date())
.notNull(),
username: text("username").notNull(),
displayUsername: text("display_username"),
role: text("role"),
banned: integer("banned", { mode: "boolean" }).default(false),
banReason: text("ban_reason"),
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).default(false),
hasDownloadedResticPassword: integer("has_downloaded_restic_password", {
mode: "boolean",
}),
});
export const sessionsTable = sqliteTable(
"sessions_table",
{
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
token: text("token").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
},
(table) => [index("sessionsTable_userId_idx").on(table.userId)],
);
export const account = sqliteTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", {
mode: "timestamp_ms",
}),
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
mode: "timestamp_ms",
}),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = sqliteTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const twoFactor = sqliteTable(
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
},
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
);
export const ssoProvider = sqliteTable("sso_provider", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
userId: text("user_id").references(() => usersTable.id, {
onDelete: "cascade",
}),
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
});
export const usersTableRelations = relations(usersTable, ({ many }) => ({
sessionsTables: many(sessionsTable),
accounts: many(account),
twoFactors: many(twoFactor),
ssoProviders: many(ssoProvider),
}));
export const sessionsTableRelations = relations(sessionsTable, ({ one }) => ({
usersTable: one(usersTable, {
fields: [sessionsTable.userId],
references: [usersTable.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
usersTable: one(usersTable, {
fields: [account.userId],
references: [usersTable.id],
}),
}));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
usersTable: one(usersTable, {
fields: [twoFactor.userId],
references: [usersTable.id],
}),
}));
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
usersTable: one(usersTable, {
fields: [ssoProvider.userId],
references: [usersTable.id],
}),
}));

View file

@ -5,6 +5,7 @@
"": {
"name": "zerobyte",
"dependencies": {
"@better-auth/sso": "^1.4.10",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -108,6 +109,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=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
@ -168,7 +171,9 @@
"@better-auth/core": ["@better-auth/core@1.4.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.7", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-VfqZwMAEl9rnGx092BIZ2Q5z8rt7jjN2OAbvPqehufSKZGmh8JsdtZRBMl/CHQir9bwi2Ev0UF4+7TQp+DXEMg=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.12", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.12" } }, "sha512-4q504Og42PzkUbZjXDt+FyeYaS0WZmAlEOC3nbBCZDObTVCRUnGgJW52B2maJ7BCVvAQgBGLEeQmQzU5+63J0A=="],
"@better-auth/sso": ["@better-auth/sso@1.4.10", "", { "dependencies": { "@better-fetch/fetch": "1.1.21", "fast-xml-parser": "^5.2.5", "jose": "^6.1.0", "samlify": "^2.10.1", "zod": "^4.1.12" }, "peerDependencies": { "better-auth": "1.4.10" } }, "sha512-td8Mg32JHpyFRIwJ6sfqZ0NDa9Easf+sXw5wnWLLgmnd7/osg4xTKTFsMLEvr5j4n/1mzSFVU/RBthOV2lCD+A=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.10", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.10" } }, "sha512-Dq4XJX6EKsUu0h3jpRagX739p/VMOTcnJYWRrLtDYkqtZFg+sFiFsSWVcfapZoWpRSUGYX9iKwl6nDHn6Ju2oQ=="],
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
@ -636,7 +641,9 @@
"@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="],
"@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="],
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@ -654,6 +661,10 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@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=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
@ -676,6 +687,8 @@
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.11", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ=="],
@ -724,6 +737,8 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@ -918,6 +933,8 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-xml-parser": ["fast-xml-parser@5.3.3", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
@ -1226,8 +1243,12 @@
"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=="],
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@ -1254,6 +1275,8 @@
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
"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=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
@ -1392,6 +1415,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.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@ -1450,6 +1475,8 @@
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"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=="],
@ -1530,6 +1557,8 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@ -1570,6 +1599,14 @@
"wsl-utils": ["wsl-utils@0.3.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ=="],
"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=="],
"xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@ -1690,6 +1727,8 @@
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="],
"@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],

View file

@ -24,6 +24,7 @@
"test:codegen": "playwright codegen localhost:4096"
},
"dependencies": {
"@better-auth/sso": "^1.4.10",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",