add: oidc,passkey,disable email/password,sign-up

This commit is contained in:
Théo LAGACHE
2026-02-13 16:48:13 +01:00
parent 7ab213e5e4
commit 3bf731055f
29 changed files with 6199 additions and 525 deletions
+18
View File
@@ -26,6 +26,24 @@ AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
AUTH_OIDC_ID=""
AUTH_OIDC_TITLE=""
AUTH_OIDC_DESC=""
AUTH_OIDC_ICON=""
AUTH_OIDC_CLIENT=""
AUTH_OIDC_SECRET=""
AUTH_OIDC_ISSUER_URL=""
AUTH_OIDC_HOST=""
AUTH_OIDC_SCOPES=""
AUTH_OIDC_DISCOVERY_ENDPOINT=""
AUTH_OIDC_JWKS_ENDPOINT=""
AUTH_OIDC_PKCE=true
ALLOWED_GROUP="admin"
AUTH_EMAIL_PASSWORD_ENABLED=true
AUTH_SIGNUP_ENABLED=true
AUTH_PASSKEY_ENABLED=true
# Retention
RETENTION_CRON="* * * * *"
+11 -7
View File
@@ -1,10 +1,15 @@
import {CardContent, CardHeader} from "@/components/ui/card";
import { CardContent, CardHeader } from "@/components/ui/card";
import {TooltipProvider} from "@/components/ui/tooltip";
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
import {CardAuth} from "@/features/layout/card-auth";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
import { env } from "@/env.mjs";
import { CardAuth } from "@/features/layout/card-auth";
import { redirect } from "next/navigation";
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
redirect("/login");
}
return (
<TooltipProvider>
@@ -12,12 +17,11 @@ export default async function RoutePage(props: { searchParams: Promise<{ callbac
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Reset password</h1>
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a
link to reset your password.</p>
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
</div>
</CardHeader>
<CardContent>
<ForgotPasswordForm/>
<ForgotPasswordForm />
</CardContent>
</CardAuth>
</TooltipProvider>
+27 -28
View File
@@ -1,19 +1,19 @@
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
import {Metadata} from "next";
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
import {TooltipProvider} from "@/components/ui/tooltip";
import {CardContent, CardHeader} from "@/components/ui/card";
import { LoginForm } from "@/components/wrappers/auth/login/login-form/login-form";
import { Metadata } from "next";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { SocialAuthButtons } from "@/components/wrappers/auth/social-buttons";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link";
import {Separator} from "@/components/ui/separator";
import {CardAuth} from "@/features/layout/card-auth";
import { Separator } from "@/components/ui/separator";
import { CardAuth } from "@/features/layout/card-auth";
import { env } from "@/env.mjs";
export const metadata: Metadata = {
title: "Login",
};
export default async function SignInPage() {
return (
<TooltipProvider>
<CardAuth className="w-full">
@@ -24,29 +24,28 @@ export default async function SignInPage() {
</div>
</CardHeader>
<CardContent className="space-y-4">
<LoginForm/>
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && <LoginForm isPasskeyEnabled={env.AUTH_PASSKEY_ENABLED === "true"} />}
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
<>
<div className="relative my-4 flex items-center justify-center overflow-hidden">
<Separator/>
<div className="px-2 text-center text-sm">OR</div>
<Separator/>
</div>
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
</>
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
<div className="relative my-4 flex items-center justify-center overflow-hidden">
<Separator />
<div className="px-2 text-center text-sm">OR</div>
<Separator />
</div>
)}
<div className="mt-4 text-center text-sm">
Don&apos;t have an account ?{" "}
<Link href="/register" className="underline">
Sign up
</Link>
</div>
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && <SocialAuthButtons providers={SUPPORTED_PROVIDERS} />}
{env.AUTH_SIGNUP_ENABLED === "true" && (
<div className="mt-4 text-center text-sm">
Don&apos;t have an account ?{" "}
<Link href="/register" className="underline">
Sign up
</Link>
</div>
)}
</CardContent>
</CardAuth>
</TooltipProvider>
)
);
}
+12 -6
View File
@@ -1,15 +1,21 @@
import {PageParams} from "@/types/next";
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
import {Metadata} from "next";
import { PageParams } from "@/types/next";
import { RegisterForm } from "@/components/wrappers/auth/register/register-form/register-form";
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { env } from "@/env.mjs";
export const metadata: Metadata = {
title: "Register",
};
export default async function RoutePage(props: PageParams<{}>) {
if (env.AUTH_SIGNUP_ENABLED !== "true") {
redirect("/login");
}
return (
<div className="mx-auto grid w-full gap-6">
<RegisterForm/>
<RegisterForm />
</div>
)
}
);
}
+5 -1
View File
@@ -4,9 +4,13 @@ import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-passwo
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import {CardAuth} from "@/features/layout/card-auth";
import { CardAuth } from "@/features/layout/card-auth";
import { env } from "@/env.mjs";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
return redirect("/login");
}
const { token } = await props.searchParams;
+41 -29
View File
@@ -1,35 +1,47 @@
name: portabase-dev
services:
db:
image: postgres:17-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
interval: 10s
timeout: 5s
retries: 5
db:
image: postgres:17-alpine
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=devdb
- POSTGRES_USER=devuser
- POSTGRES_PASSWORD=changeme
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
interval: 10s
timeout: 5s
retries: 5
tusd:
image: tusproject/tusd:v2.8.0
ports:
- "1080:8080"
command: >
-upload-dir /data/uploads/tmp
-hooks-http http://localhost:8887/api/tus/hooks
-max-size 21474836480
-base-path /tus/files/
extra_hosts:
- "localhost:host-gateway"
volumes:
- ./private/uploads/tmp:/data/uploads/tmp
tusd:
image: tusproject/tusd:v2.8.0
ports:
- "1080:8080"
command: >
-upload-dir /data/uploads/tmp
-hooks-http http://localhost:8887/api/tus/hooks
-max-size 21474836480
-base-path /tus/files/
extra_hosts:
- "localhost:host-gateway"
volumes:
- ./private/uploads/tmp:/data/uploads/tmp
keycloak:
image: quay.io/keycloak/keycloak:latest
command: start-dev
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
volumes:
- keycloak-data:/opt/keycloak/data
volumes:
postgres-data:
postgres-data:
keycloak-data:
+3 -1
View File
@@ -14,6 +14,8 @@
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
},
"dependencies": {
"@better-auth/passkey": "^1.4.18",
"@better-auth/sso": "^1.4.18",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -53,7 +55,7 @@
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.4.5",
"better-auth": "1.4.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+429 -36
View File
@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@better-auth/passkey':
specifier: ^1.4.18
version: 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.18(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.9)(drizzle-orm@0.43.1(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(better-call@1.1.8(zod@3.25.76))(nanostores@1.1.0)
'@better-auth/sso':
specifier: ^1.4.18
version: 1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.9)(drizzle-orm@0.43.1(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.71.1(react@19.2.4))
@@ -126,8 +132,8 @@ importers:
specifier: ^6.0.0
version: 6.0.0
better-auth:
specifier: 1.4.5
version: 1.4.5(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: 1.4.18
version: 1.4.18(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.9)(drizzle-orm@0.43.1(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -350,6 +356,10 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@authenio/xml-encryption@2.0.2':
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
engines: {node: '>=12'}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -437,26 +447,42 @@ packages:
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
'@better-auth/core@1.4.5':
resolution: {integrity: sha512-dQ3hZOkUJzeBXfVEPTm2LVbzmWwka1nqd9KyWmB2OMlMfjr7IdUeBX4T7qJctF67d7QDhlX95jMoxu6JG0Eucw==}
'@better-auth/core@1.4.18':
resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==}
peerDependencies:
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18
better-call: 1.1.4
'@better-fetch/fetch': 1.1.21
better-call: 1.1.8
jose: ^6.1.0
kysely: ^0.28.5
nanostores: ^1.0.1
'@better-auth/telemetry@1.4.5':
resolution: {integrity: sha512-r3NyksbaBYA10SC86JA6QwmZfHwFutkUGcphgWGfu6MVx1zutYmZehIeC8LxTjOWZqqF9FI8vLjglWBHvPQeTg==}
'@better-auth/passkey@1.4.18':
resolution: {integrity: sha512-27YfrHCc95fm9e6V3RSN44JZunGHbQd0Lc26ErndPl2bCQ0VMJQi70WNLu6iqEiZYG8YSnxbMOg4/ivk8D7+lA==}
peerDependencies:
'@better-auth/core': 1.4.5
'@better-auth/core': 1.4.18
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
better-auth: 1.4.18
better-call: 1.1.8
nanostores: ^1.0.1
'@better-auth/sso@1.4.18':
resolution: {integrity: sha512-jwTxZUBp71W6YVOavy50DBZ4OFi0a9MGvTTAi+mxMuINFRcz2RyGZv0gb3jy8AJT97TwKU60qRYIWYYyJj1uhA==}
peerDependencies:
'@better-auth/utils': 0.3.0
better-auth: 1.4.18
'@better-auth/telemetry@1.4.18':
resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==}
peerDependencies:
'@better-auth/core': 1.4.18
'@better-auth/utils@0.3.0':
resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==}
'@better-fetch/fetch@1.1.18':
resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==}
'@better-fetch/fetch@1.1.21':
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
'@chevrotain/cst-dts-gen@10.4.2':
resolution: {integrity: sha512-0+4bNjlndNWMoVLH/+y4uHnf6GrTipsC+YTppJxelVJo+xeRVQ0s2PpkdDCVTsu7efyj+8r1gFiwVXsp6JZ0iQ==}
@@ -1160,6 +1186,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
peerDependencies:
@@ -1516,6 +1545,9 @@ packages:
'@js-sdsl/ordered-map@4.4.2':
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@lottiefiles/dotlottie-react@0.13.3':
resolution: {integrity: sha512-V4FfdYlqzjBUX7f0KV6vfQOOI0Cp+3XeG/ZqSDFSEVg5P7fpROpDv5/I9aTM8sOCESK1SWT96Fem+QVUnBV1wQ==}
peerDependencies:
@@ -1671,6 +1703,43 @@ packages:
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@peculiar/asn1-android@2.6.0':
resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==}
'@peculiar/asn1-cms@2.6.1':
resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==}
'@peculiar/asn1-csr@2.6.1':
resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==}
'@peculiar/asn1-ecc@2.6.1':
resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==}
'@peculiar/asn1-pfx@2.6.1':
resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==}
'@peculiar/asn1-pkcs8@2.6.1':
resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==}
'@peculiar/asn1-pkcs9@2.6.1':
resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==}
'@peculiar/asn1-rsa@2.6.1':
resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==}
'@peculiar/asn1-schema@2.6.0':
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
'@peculiar/asn1-x509-attr@2.6.1':
resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==}
'@peculiar/asn1-x509@2.6.1':
resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==}
'@peculiar/x509@1.14.3':
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
@@ -2667,6 +2736,13 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@simplewebauthn/browser@13.2.2':
resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==}
'@simplewebauthn/server@13.2.2':
resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==}
engines: {node: '>=20.0.0'}
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
@@ -3161,6 +3237,14 @@ packages:
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
'@xmldom/is-dom-node@1.0.1':
resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==}
engines: {node: '>= 16'}
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -3344,6 +3428,10 @@ packages:
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
asn1js@3.0.7:
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
engines: {node: '>=12.0.0'}
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
@@ -3405,27 +3493,55 @@ packages:
bcryptjs@2.4.3:
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
better-auth@1.4.5:
resolution: {integrity: sha512-pHV2YE0OogRHvoA6pndHXCei4pcep/mjY7psSaHVrRgjBtumVI68SV1g9U9XPRZ4KkoGca9jfwuv+bB2UILiFw==}
better-auth@1.4.18:
resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==}
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
peerDependenciesMeta:
'@lynx-js/react':
optional: true
'@prisma/client':
optional: true
'@sveltejs/kit':
optional: true
'@tanstack/react-start':
optional: true
'@tanstack/solid-start':
optional: true
better-sqlite3:
optional: true
drizzle-kit:
optional: true
drizzle-orm:
optional: true
mongodb:
optional: true
mysql2:
optional: true
next:
optional: true
pg:
optional: true
prisma:
optional: true
react:
optional: true
react-dom:
@@ -3434,11 +3550,13 @@ packages:
optional: true
svelte:
optional: true
vitest:
optional: true
vue:
optional: true
better-call@1.1.4:
resolution: {integrity: sha512-NJouLY6IVKv0nDuFoc6FcbKDFzEnmgMNofC9F60Mwx1Ecm7X6/Ecyoe5b+JSVZ42F/0n46/M89gbYP1ZCVv8xQ==}
better-call@1.1.8:
resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==}
peerDependencies:
zod: ^4.0.0
peerDependenciesMeta:
@@ -3538,6 +3656,10 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001769:
resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==}
@@ -4337,6 +4459,10 @@ packages:
resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==}
hasBin: true
fast-xml-parser@5.3.5:
resolution: {integrity: sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA==}
hasBin: true
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -5221,10 +5347,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
ms@4.0.0-nightly.202508271359:
resolution: {integrity: sha512-WC/Eo7NzFrOV/RRrTaI0fxKVbNCzEy76j2VqNV8SxDf9D69gSE2Lh0QwYvDlhiYmheBYExAvEAxVf5NoN0cj2A==}
engines: {node: '>=20'}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -5375,6 +5497,9 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
node-rsa@1.1.1:
resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==}
nodemailer@7.0.13:
resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==}
engines: {node: '>=6.0.0'}
@@ -5489,6 +5614,9 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
param-case@3.0.4:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
@@ -5729,6 +5857,13 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.5:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
qr.js@0.0.0:
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
@@ -5897,6 +6032,9 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -5979,6 +6117,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
samlify@2.10.2:
resolution: {integrity: sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA==}
sax@1.4.4:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
@@ -6235,6 +6376,9 @@ packages:
strnum@1.1.2:
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
strnum@2.1.2:
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -6391,6 +6535,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -6399,6 +6546,10 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tsyringe@4.10.0:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@@ -6537,6 +6688,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
@@ -6693,10 +6848,20 @@ packages:
utf-8-validate:
optional: true
xml-crypto@6.1.2:
resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==}
engines: {node: '>=16'}
xml-escape@1.1.0:
resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==}
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xml@1.0.1:
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
@@ -6705,6 +6870,14 @@ packages:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'}
xpath@0.0.32:
resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==}
engines: {node: '>=0.6.0'}
xpath@0.0.33:
resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==}
engines: {node: '>=0.6.0'}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -6774,6 +6947,12 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@authenio/xml-encryption@2.0.2':
dependencies:
'@xmldom/xmldom': 0.8.11
escape-html: 1.0.3
xpath: 0.0.32
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -6923,26 +7102,48 @@ snapshots:
'@balena/dockerignore@1.0.2': {}
'@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)':
'@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)':
dependencies:
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18
'@better-fetch/fetch': 1.1.21
'@standard-schema/spec': 1.1.0
better-call: 1.1.4(zod@4.3.6)
better-call: 1.1.8(zod@3.25.76)
jose: 6.1.3
kysely: 0.28.11
nanostores: 1.1.0
zod: 4.3.6
'@better-auth/telemetry@1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))':
'@better-auth/passkey@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.18(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.9)(drizzle-orm@0.43.1(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(better-call@1.1.8(zod@3.25.76))(nanostores@1.1.0)':
dependencies:
'@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18
'@better-fetch/fetch': 1.1.21
'@simplewebauthn/browser': 13.2.2
'@simplewebauthn/server': 13.2.2
better-auth: 1.4.18(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.9)(drizzle-orm@0.43.1(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
better-call: 1.1.8(zod@3.25.76)
nanostores: 1.1.0
zod: 4.3.6
'@better-auth/sso@1.4.18(@better-auth/utils@0.3.0)(better-auth@1.4.18(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.9)(drizzle-orm@0.43.1(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
dependencies:
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
better-auth: 1.4.18(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.9)(drizzle-orm@0.43.1(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
fast-xml-parser: 5.3.5
jose: 6.1.3
samlify: 2.10.2
zod: 4.3.6
'@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))':
dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
'@better-auth/utils@0.3.0': {}
'@better-fetch/fetch@1.1.18': {}
'@better-fetch/fetch@1.1.21': {}
'@chevrotain/cst-dts-gen@10.4.2':
dependencies:
@@ -7377,6 +7578,8 @@ snapshots:
protobufjs: 7.5.4
yargs: 17.7.2
'@hexagon/base64@1.1.28': {}
'@hookform/resolvers@5.2.2(react-hook-form@7.71.1(react@19.2.4))':
dependencies:
'@standard-schema/utils': 0.3.0
@@ -7625,6 +7828,8 @@ snapshots:
'@js-sdsl/ordered-map@4.4.2': {}
'@levischuck/tiny-cbor@0.2.11': {}
'@lottiefiles/dotlottie-react@0.13.3(react@19.0.0)':
dependencies:
'@lottiefiles/dotlottie-web': 0.42.0
@@ -7719,6 +7924,102 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
'@peculiar/asn1-android@2.6.0':
dependencies:
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-cms@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/asn1-x509-attr': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-csr@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-ecc@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pfx@2.6.1':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-pkcs8': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.6.1':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-pfx': 2.6.1
'@peculiar/asn1-pkcs8': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/asn1-x509-attr': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-rsa@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-schema@2.6.0':
dependencies:
asn1js: 3.0.7
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
asn1js: 3.0.7
tslib: 2.8.1
'@peculiar/asn1-x509@2.6.1':
dependencies:
'@peculiar/asn1-schema': 2.6.0
asn1js: 3.0.7
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/x509@1.14.3':
dependencies:
'@peculiar/asn1-cms': 2.6.1
'@peculiar/asn1-csr': 2.6.1
'@peculiar/asn1-ecc': 2.6.1
'@peculiar/asn1-pkcs9': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.10.0
'@phc/format@1.0.0': {}
'@pkgjs/parseargs@0.11.0':
@@ -9204,6 +9505,19 @@ snapshots:
domhandler: 5.0.3
selderee: 0.11.0
'@simplewebauthn/browser@13.2.2': {}
'@simplewebauthn/server@13.2.2':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11
'@peculiar/asn1-android': 2.6.0
'@peculiar/asn1-ecc': 2.6.1
'@peculiar/asn1-rsa': 2.6.1
'@peculiar/asn1-schema': 2.6.0
'@peculiar/asn1-x509': 2.6.1
'@peculiar/x509': 1.14.3
'@socket.io/component-emitter@3.1.2': {}
'@standard-schema/spec@1.1.0': {}
@@ -9700,6 +10014,10 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2
'@xmldom/is-dom-node@1.0.1': {}
'@xmldom/xmldom@0.8.11': {}
'@xtuc/ieee754@1.2.0': {}
'@xtuc/long@4.2.2': {}
@@ -10018,6 +10336,12 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
asn1js@3.0.7:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.5
tslib: 2.8.1
ast-types-flow@0.0.8: {}
async-exit-hook@2.0.1: {}
@@ -10065,30 +10389,43 @@ snapshots:
bcryptjs@2.4.3: {}
better-auth@1.4.5(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
better-auth@1.4.18(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(drizzle-kit@0.31.9)(drizzle-orm@0.43.1(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3)))(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@better-auth/core': 1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.5(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.4(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1
'@noble/hashes': 2.0.1
better-call: 1.1.4(zod@4.3.6)
better-call: 1.1.8(zod@4.3.6)
defu: 6.1.4
jose: 6.1.3
kysely: 0.28.11
ms: 4.0.0-nightly.202508271359
nanostores: 1.1.0
zod: 4.3.6
optionalDependencies:
'@prisma/client': 6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3)
drizzle-kit: 0.31.9
drizzle-orm: 0.43.1(@prisma/client@6.7.0(prisma@6.7.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)(prisma@6.7.0(typescript@5.9.3))
next: 16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.18.0
prisma: 6.7.0(typescript@5.9.3)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
better-call@1.1.4(zod@4.3.6):
better-call@1.1.8(zod@3.25.76):
dependencies:
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18
'@better-fetch/fetch': 1.1.21
rou3: 0.7.12
set-cookie-parser: 2.7.2
optionalDependencies:
zod: 3.25.76
better-call@1.1.8(zod@4.3.6):
dependencies:
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
rou3: 0.7.12
set-cookie-parser: 2.7.2
optionalDependencies:
@@ -10209,6 +10546,8 @@ snapshots:
camelcase-css@2.0.1: {}
camelcase@6.3.0: {}
caniuse-lite@1.0.30001769: {}
capital-case@1.0.4:
@@ -11217,6 +11556,10 @@ snapshots:
dependencies:
strnum: 1.1.2
fast-xml-parser@5.3.5:
dependencies:
strnum: 2.1.2
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -12070,8 +12413,6 @@ snapshots:
ms@2.1.3: {}
ms@4.0.0-nightly.202508271359: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0
@@ -12189,6 +12530,10 @@ snapshots:
node-releases@2.0.27: {}
node-rsa@1.1.1:
dependencies:
asn1: 0.2.6
nodemailer@7.0.13: {}
normalize-path@3.0.0: {}
@@ -12328,6 +12673,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
param-case@3.0.4:
dependencies:
dot-case: 3.0.4
@@ -12559,6 +12906,12 @@ snapshots:
pure-rand@6.1.0: {}
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.5: {}
qr.js@0.0.0: {}
qs@6.14.1:
@@ -12784,6 +13137,8 @@ snapshots:
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
reflect-metadata@0.2.2: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -12883,6 +13238,20 @@ snapshots:
safer-buffer@2.1.2: {}
samlify@2.10.2:
dependencies:
'@authenio/xml-encryption': 2.0.2
'@xmldom/xmldom': 0.8.11
camelcase: 6.3.0
node-forge: 1.3.3
node-rsa: 1.1.1
pako: 1.0.11
uuid: 8.3.2
xml: 1.0.1
xml-crypto: 6.1.2
xml-escape: 1.1.0
xpath: 0.0.32
sax@1.4.4: {}
scheduler@0.25.0: {}
@@ -13270,6 +13639,8 @@ snapshots:
strnum@1.1.2: {}
strnum@2.1.2: {}
styled-jsx@5.1.6(@babel/core@7.26.10)(react@19.0.0):
dependencies:
client-only: 0.0.1
@@ -13456,6 +13827,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@1.14.1: {}
tslib@2.8.1: {}
tsx@4.21.0:
@@ -13465,6 +13838,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tsyringe@4.10.0:
dependencies:
tslib: 1.14.1
tw-animate-css@1.4.0: {}
tweetnacl@0.14.5: {}
@@ -13651,6 +14028,8 @@ snapshots:
uuid@11.1.0: {}
uuid@8.3.2: {}
uuid@9.0.1: {}
vary@1.1.2: {}
@@ -13844,15 +14223,29 @@ snapshots:
ws@8.19.0: {}
xml-crypto@6.1.2:
dependencies:
'@xmldom/is-dom-node': 1.0.1
'@xmldom/xmldom': 0.8.11
xpath: 0.0.33
xml-escape@1.1.0: {}
xml2js@0.6.2:
dependencies:
sax: 1.4.4
xmlbuilder: 11.0.1
xml@1.0.1: {}
xmlbuilder@11.0.1: {}
xmlhttprequest-ssl@2.1.2: {}
xpath@0.0.32: {}
xpath@0.0.33: {}
xtend@4.0.2: {}
y18n@5.0.8: {}
-47
View File
@@ -1,17 +1,3 @@
export interface AuthProviderConfig {
id: "google" | "github" | "credential";
isActive: boolean;
icon: string;
isManual?: boolean;
credentials?: {
clientId: string;
clientSecret: string;
};
}
export const PORTABASE_DEFAULT_SETTINGS = {
SECURITY: {
CSP: {
@@ -75,36 +61,3 @@ export const PORTABASE_DEFAULT_SETTINGS = {
},
},
};
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
{
id: "google",
icon: "hugeicons:chrome",
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
// isManual: true,
credentials: {
clientId: process.env.AUTH_GOOGLE_ID || "",
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
},
},
{
id: "github",
icon: "iconoir:github",
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
credentials: {
clientId: process.env.AUTH_GITHUB_ID || "",
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
},
},
{
id: "credential",
isActive: true,
icon: "proicons:key",
isManual: true,
credentials: {
clientId: "",
clientSecret: "",
},
},
];
@@ -15,9 +15,11 @@ import {PasswordInput} from "@/components/ui/password-input";
export type loginFormProps = {
defaultValues?: LoginType;
isPasskeyEnabled?: boolean;
};
export const LoginForm = (props: loginFormProps) => {
const {isPasskeyEnabled = false} = props;
const [urlParams, setUrlParams] = useState<URLSearchParams>();
@@ -106,7 +108,7 @@ export const LoginForm = (props: loginFormProps) => {
</div>
</div>
<FormControl>
<PasswordInput autoComplete="current-password webauthn"
<PasswordInput autoComplete={isPasskeyEnabled ? "current-password webauthn" : "current-password"}
placeholder={"Enter your password"} {...field} />
</FormControl>
<FormMessage/>
+53 -35
View File
@@ -1,29 +1,52 @@
"use client";
import React, { useState } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/auth-client";
import { authClient, passkey } from "@/lib/auth/auth-client";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import {AuthProviderConfig} from "../../../../portabase.config";
import {Icon} from "@iconify/react";
import { Icon } from "@iconify/react";
import { AuthProviderConfig } from "@/lib/auth/config";
import Image from "next/image";
import { useRouter } from "next/navigation";
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
const socialProviders = providers.filter(p => p.isActive && !p.isManual);
const socialProviders = providers.filter((p) => p.isActive && p.type !== "credential");
const router = useRouter();
const [isLoading, setIsLoading] = useState<string | null>(null);
const handleSocialSignIn = async (providerId: string) => {
setIsLoading(providerId);
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
setIsLoading(provider.id);
try {
const { error } = await authClient.signIn.social({
provider: providerId as "google" | "github",
callbackURL: "/dashboard",
});
if (error) {
toast.error("An error occurred while signing in with the provider. Please try again.");
let result;
if (provider.id === "passkey") {
result = await authClient.signIn.passkey({
fetchOptions: {
onSuccess() {
router.push("/dashboard");
},
onError() {
toast.error("An error occurred during passkey authentication. Please try again.");
},
},
});
} else if (provider.type === "sso") {
result = await authClient.signIn.sso({
providerId: provider.id,
providerType: "oidc",
callbackURL: "/dashboard",
});
} else {
result = await authClient.signIn.social({
provider: provider.id as "google" | "github",
callbackURL: "/dashboard",
});
}
if (result?.error) {
toast.error("An error occurred while signing in with the provider. Please try again.");
} else if (provider.id !== "passkey") {
toast.success("Redirecting to provider...");
}
} catch (err) {
@@ -38,29 +61,24 @@ export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig
return (
<div className="flex flex-col gap-2 w-full">
{socialProviders.map((provider) => (
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}>
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> :
<Icon icon={provider.icon} className="h-4 w-4"/>
}
<span>{PROVIDERS_TEXT[provider.id].title}</span>
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider)} disabled={!!isLoading}>
{isLoading === provider.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id || "icon"}
width={16}
height={16}
className="h-4 w-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="h-4 w-4" />
)}
<span>{provider.title || provider.name}</span>
</Button>
))}
</div>
);
}
const PROVIDERS_TEXT = {
credential: {
title: "Password",
description: "Use your email address and password to sign in."
},
google: {
title: "Google",
description: "Sign in with your Google account."
},
github: {
title: "GitHub",
description: "Sign in with your GitHub account."
}
}
@@ -1,8 +1,7 @@
import {currentUser} from "@/lib/auth/current-user";
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
import {LoggedInButtonClient} from "./logged-in-button";
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
import { currentUser } from "@/lib/auth/current-user";
import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
import { LoggedInButtonClient } from "./logged-in-button";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
export const LoggedInButton = async () => {
const user = await currentUser();
@@ -10,7 +9,6 @@ export const LoggedInButton = async () => {
const currentSession = await getSession();
const accounts = await getAccounts();
if (!user) return null;
return (
@@ -1,29 +1,21 @@
"use client";
import {ChevronsUpDown} from "lucide-react";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {SidebarMenuButton} from "@/components/ui/sidebar";
import {LoggedInDropdown} from "./logged-in-dropdown";
import {Account, Session, User} from "better-auth";
import {AuthProviderConfig} from "../../../../../../portabase.config";
import { ChevronsUpDown } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { LoggedInDropdown } from "./logged-in-dropdown";
import { Account, Session, User } from "better-auth";
import { AuthProviderConfig } from "@/lib/auth/config";
type LoggedInButtonClientProps = {
user: User,
sessions: Session[],
currentSession: Session,
accounts: Account[],
providers: AuthProviderConfig[]
}
export const LoggedInButtonClient = ({
user,
sessions,
currentSession,
accounts,
providers
}: LoggedInButtonClientProps) => {
user: User;
sessions: Session[];
currentSession: Session;
accounts: Account[];
providers: AuthProviderConfig[];
};
export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers }: LoggedInButtonClientProps) => {
return (
<LoggedInDropdown
// @ts-ignore
@@ -40,20 +32,16 @@ export const LoggedInButtonClient = ({
<div className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
{user.image && <AvatarImage src={user.image}/>}
{user.image && <AvatarImage src={user.image} />}
</Avatar>
<div className="flex flex-col items-start">
<span
className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
<span
className="text-xs text-muted-foreground max-w-[170px] truncate"
title={user.email}
>
{user.email}
</span>
<span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
<span className="text-xs text-muted-foreground max-w-[170px] truncate" title={user.email}>
{user.email}
</span>
</div>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50"/>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</SidebarMenuButton>
</LoggedInDropdown>
);
@@ -1,6 +1,6 @@
"use client";
import {PropsWithChildren, ReactNode, useState} from "react";
import { PropsWithChildren, ReactNode, useState } from "react";
import { useRouter } from "next/navigation";
@@ -10,11 +10,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
import { signOut } from "@/lib/auth/auth-client";
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
import { ProfileModal } from "@/components/wrappers/dashboard/common/profile/profile-modal";
import {Account, Session, User as UserType} from "@/db/schema/02_user";
import { Account, Session, User as UserType } from "@/db/schema/02_user";
import {AuthProviderConfig} from "../../../../../../portabase.config";
import { AuthProviderConfig } from "@/lib/auth/config";
export type LoggedInDropdownProps = PropsWithChildren<{
user: UserType;
@@ -22,13 +22,10 @@ export type LoggedInDropdownProps = PropsWithChildren<{
currentSession: Session;
accounts: Account[];
children: ReactNode;
providers: AuthProviderConfig[]
providers: AuthProviderConfig[];
}>;
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
const router = useRouter();
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -44,50 +41,46 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
onOpenChange={setIsModalOpen}
providers={providers}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
align="start"
side="top"
sideOffset={8}
>
<DropdownMenuItem
onClick={() => setIsModalOpen(!isModalOpen)}
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
>
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Account Settings</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
onClick={async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
},
});
}}
>
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
<LogOut size={18} className="text-red-500" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Logout</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
align="start"
side="top"
sideOffset={8}
>
<DropdownMenuItem
onClick={() => setIsModalOpen(!isModalOpen)}
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
>
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Account Settings</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
onClick={async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
},
});
}}
>
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
<LogOut size={18} className="text-red-500" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Logout</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
@@ -1,16 +1,15 @@
"use client";
import React from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Account, Session, User } from "@/db/schema/02_user";
import { ProfileSidebar } from "./profile-sidebar";
import {ProfileProviders} from "@/components/wrappers/dashboard/profile/profile-providers";
import {ProfileAccount} from "@/components/wrappers/dashboard/profile/profile-account";
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance";
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security";
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general";
import {AuthProviderConfig} from "../../../../../../portabase.config";
import { AuthProviderConfig } from "@/lib/auth/config";
import { User, Session, Account } from "@/db/schema/02_user";
import { ProfileGeneral } from "../../profile/profile-general";
import { ProfileSecurity } from "../../profile/profile-security";
import { ProfileProviders } from "../../profile/profile-providers";
import { ProfileAccount } from "../../profile/profile-account";
import { ProfileAppearance } from "../../profile/profile-apperance";
type ProfileModalProps = {
open: boolean;
@@ -19,14 +18,13 @@ type ProfileModalProps = {
currentSession: Session;
accounts: Account[];
onOpenChange: (open: boolean) => void;
providers: AuthProviderConfig[]
providers: AuthProviderConfig[];
};
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
<DialogHeader className="sr-only">
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Manage your account settings</DialogDescription>
@@ -45,6 +43,8 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
sessions={sessions}
currentSession={currentSession}
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
isPasswordEnabled={providers.some((p) => p.id === "credential")}
isPasskeyEnabled={providers.some((p) => p.id === "passkey")}
/>
</TabsContent>
@@ -1,6 +1,6 @@
"use client";
import React, { use } from "react";
import React from "react";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react";
import { User } from "@/db/schema/02_user";
@@ -10,7 +10,6 @@ interface ProfileSidebarProps {
}
export function ProfileSidebar({ user }: ProfileSidebarProps) {
return (
<div className="w-full lg:w-[260px] flex-shrink-0 lg:border-r bg-muted/10 p-4 lg:p-6 flex flex-col gap-4 border-b lg:border-b-0">
<div className="flex items-center px-2 mb-2">
@@ -3,13 +3,18 @@
import { ServerActionResult } from "@/types/action-type";
import { z } from "zod";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/auth";
import {userAction} from "@/lib/safe-actions/actions";
import { auth, getPasskeys, revokePasskey } from "@/lib/auth/auth";
import { userAction } from "@/lib/safe-actions/actions";
const RevokeSessionSchema = z.object({
token: z.string(),
});
const RevokePasskeySchema = z.object({
id: z.string(),
});
export const revokeSessionAction = userAction.schema(RevokeSessionSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
try {
const session = await auth.api.getSession({
@@ -99,3 +104,46 @@ export const revokeAllSessionsAction = userAction.action(async (): Promise<Serve
};
}
});
export const getPasskeysAction = userAction.action(async (): Promise<ServerActionResult<any[]>> => {
try {
const passkeys = await getPasskeys();
return {
success: true,
value: passkeys || [],
actionSuccess: {
message: "passkeys_fetched",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_fetching_passkeys",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const revokePasskeyAction = userAction.schema(RevokePasskeySchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
try {
await revokePasskey(parsedInput.id);
return {
success: true,
value: {},
actionSuccess: {
message: "passkey_revoked",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_revoking_passkey",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -1,37 +1,37 @@
"use client";
import React, {useState} from "react";
import {Button} from "@/components/ui/button";
import {Badge} from "@/components/ui/badge";
import {Loader2, AlertTriangle} from "lucide-react";
import {Account} from "@/db/schema/02_user";
import {authClient} from "@/lib/auth/auth-client";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {Alert, AlertDescription} from "@/components/ui/alert";
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
import {AuthProviderConfig} from "../../../../../portabase.config";
import {Icon} from "@iconify/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Loader2, AlertTriangle } from "lucide-react";
import { authClient } from "@/lib/auth/auth-client";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
import { Icon } from "@iconify/react";
import Image from "next/image";
import { AuthProviderConfig } from "@/lib/auth/config";
import { Account } from "@/db/schema/02_user";
interface ProfileProviderProps {
accounts: Account[];
providers: AuthProviderConfig[]
providers: AuthProviderConfig[];
}
export function ProfileProviders({accounts, providers}: ProfileProviderProps) {
export function ProfileProviders({ accounts, providers }: ProfileProviderProps) {
const router = useRouter();
const totalConnected = accounts.length;
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const {mutate: unlinkAccount} = useMutation({
const { mutate: unlinkAccount } = useMutation({
mutationFn: async (providerId: string) => {
setLoadingProvider(providerId);
const {error} = await authClient.unlinkAccount({ providerId });
const { error } = await authClient.unlinkAccount({ providerId });
if (error) throw error;
},
onSuccess: () => {
@@ -45,17 +45,25 @@ export function ProfileProviders({accounts, providers}: ProfileProviderProps) {
},
});
const {mutate: linkAccount} = useMutation({
mutationFn: async (providerId: string) => {
setLoadingProvider(providerId);
const {error} = await authClient.signIn.social({
provider: providerId as "google" | "github" | "credential",
callbackURL: "/dashboard",
});
if (error) throw error;
const { mutate: linkAccount } = useMutation({
mutationFn: async (provider: AuthProviderConfig) => {
setLoadingProvider(provider.id);
let result;
if (provider.type === "sso") {
result = await authClient.signIn.sso({
providerId: provider.id,
providerType: "oidc",
callbackURL: "/dashboard",
});
} else {
result = await authClient.signIn.social({
provider: provider.id as "google" | "github" | "credential",
callbackURL: "/dashboard",
});
}
if (result.error) throw result.error;
},
onSuccess: () => {
toast.success("Provider successfully Linked!");
setLoadingProvider(null);
router.refresh();
},
@@ -65,120 +73,108 @@ export function ProfileProviders({accounts, providers}: ProfileProviderProps) {
},
});
const enterpriseProviders = providers.filter((p) => p.type === "sso" && p.id !== "passkey" && p.type !== "credential");
const otherProviders = providers.filter((p) => p.type !== "sso" && p.id !== "passkey" && p.type !== "credential");
const renderProvider = (provider: AuthProviderConfig) => {
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
const isConnected = !!linkedAccount;
const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
const isLoading = loadingProvider === provider.id;
return (
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
{provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id}
width={20}
height={20}
className="w-5 h-5"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="w-5 h-5" />
)}
</div>
<div className="space-y-0.5">
<div className="font-medium flex items-center gap-2">
{provider.title || provider.name}
{isConnected && (
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">{provider.description}</div>
</div>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>
<Button
variant="outline"
size="sm"
onClick={() => unlinkAccount(provider.id)}
disabled={!canUnlink || isLoading || provider.isManual}
className={!canUnlink ? "opacity-50 cursor-not-allowed" : ""}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
</Button>
</span>
</TooltipTrigger>
{!canUnlink && (
<TooltipContent>
<p>You cannot unlink your last authentication provider.</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) : (
<>
{provider.id === "credential" ? (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} />
) : (
<Button variant="default" size="sm" onClick={() => linkAccount(provider)} disabled={isLoading || provider.isManual}>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"}
</Button>
)}
</>
)}
</div>
</div>
);
};
return (
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">Connected Accounts</h2>
<p className="text-sm text-muted-foreground">Manage the providers used to sign in to your account.</p>
<h2 className="text-2xl font-semibold tracking-tight">Authentication</h2>
<p className="text-sm text-muted-foreground">Manage how you access your account.</p>
</div>
<div className="grid gap-4">
{providers.map((provider) => {
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
const isConnected = !!linkedAccount;
// const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
const canUnlink = totalConnected > 1 ;
// const isLoading = isUnlinking || isLinking;
const isLoading = loadingProvider === provider.id;
{enterpriseProviders.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Enterprise Connection</h3>
<div className="grid gap-4">{enterpriseProviders.map(renderProvider)}</div>
</div>
)}
return (
<div key={provider.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<Icon icon={provider.icon} className="w-5 h-5"/>
</div>
<div className="space-y-0.5">
<div className="font-medium flex items-center gap-2">
{PROVIDERS_TEXT[provider.id].title}
{isConnected && (
<Badge variant="secondary"
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
{isConnected ? "Connected" : "Not Connected"}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>
<Button
variant="outline"
size="sm"
onClick={() => unlinkAccount(provider.id)}
disabled={!canUnlink || isLoading || provider.isManual}
className={!canUnlink ? "opacity-50 cursor-not-allowed" : ""}
>
{isLoading ? <Loader2
className="w-4 h-4 animate-spin"/> : "Unlink"}
</Button>
</span>
</TooltipTrigger>
{!canUnlink && (
<TooltipContent>
<p>
You cannot unlink your last authentication provider or if you
don't have a password set.
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) : (
<>
{provider.id === "credential" ? (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}/>
) : (
<Button
variant="default"
size="sm"
onClick={() => linkAccount(provider.id)}
disabled={isLoading || provider.isManual}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin"/> : "Link"}
</Button>
)}
</>
)}
</div>
</div>
);
})}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Standard Connections</h3>
<div className="grid gap-4">{otherProviders.map(renderProvider)}</div>
</div>
<Alert variant={"default"}>
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5"/>
<AlertDescription>
Linked providers allow you to log in to your account using any of these methods. If you use the same
email address with another provider, it will be automatically linked when you log in.
</AlertDescription>
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
<AlertDescription>Linked providers allow you to log in to your account using any of these methods.</AlertDescription>
</Alert>
</div>
);
}
const PROVIDERS_TEXT = {
credential: {
title: "Password",
description: "Use your email address and password to sign in.",
},
google: {
title: "Google",
description: "Sign in with your Google account.",
},
github: {
title: "GitHub",
description: "Sign in with your GitHub account.",
},
};
@@ -1,41 +1,49 @@
"use client";
import {useState} from "react";
import {Button} from "@/components/ui/button";
import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
import {Globe, LogOut, Loader2} from "lucide-react";
import {Account, Session, User} from "@/db/schema/02_user";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {revokeAllSessionsAction, revokeSessionAction} from "./actions/security.action";
import {useRouter} from "next/navigation";
import {ResetPasswordProfileProviderModal} from "./modal/reset-password-modal";
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
import {Setup2FAProfileProviderModal} from "./modal/setup-2fa-modal";
import {Disable2FAProfileProviderModal} from "./modal/disable-2fa-modal";
import {ViewBackupCodesModal} from "./modal/view-backup-codes-modal";
import {getDeviceDetails} from "@/utils/detection";
import {timeAgo} from "@/utils/date-formatting";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Globe, LogOut, Loader2, Fingerprint, Trash2, Plus } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { revokeAllSessionsAction, revokeSessionAction, getPasskeysAction, revokePasskeyAction } from "./actions/security.action";
import { useRouter } from "next/navigation";
import { ResetPasswordProfileProviderModal } from "./modal/reset-password-modal";
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
import { Setup2FAProfileProviderModal } from "./modal/setup-2fa-modal";
import { Disable2FAProfileProviderModal } from "./modal/disable-2fa-modal";
import { ViewBackupCodesModal } from "./modal/view-backup-codes-modal";
import { getDeviceDetails } from "@/utils/detection";
import { timeAgo } from "@/utils/date-formatting";
import { authClient } from "@/lib/auth/auth-client";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Account, Session, User } from "@/db/schema/02_user";
interface ProfileSecurityProps {
user: User;
sessions: Session[];
credentialAccount: Account;
currentSession: Session;
isPasswordEnabled?: boolean;
isPasskeyEnabled?: boolean;
}
export function ProfileSecurity({user, sessions, credentialAccount, currentSession}: ProfileSecurityProps) {
export function ProfileSecurity({ user, sessions, credentialAccount, currentSession, isPasswordEnabled = false, isPasskeyEnabled = false }: ProfileSecurityProps) {
const router = useRouter();
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const {mutate: revokeSession, isPending: isRevoking} = useMutation({
const { mutate: revokeSession, isPending: isRevoking } = useMutation({
mutationFn: async (token: string) => {
const result = await revokeSessionAction({token});
const result = await revokeSessionAction({ token });
const inner = result?.data;
if (inner?.success) {
toast.success("Session successfully revoked");
@@ -46,7 +54,7 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
},
});
const {mutate: revokeOthers, isPending: isRevokingOthers} = useMutation({
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
mutationFn: async () => {
const result = await revokeAllSessionsAction();
const inner = result?.data;
@@ -59,71 +67,173 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
},
});
const {
data: passkeys,
isLoading: isLoadingPasskeys,
refetch: refetchPasskeys,
} = useQuery({
queryKey: ["passkeys"],
queryFn: async () => {
const result = await getPasskeysAction();
if (result?.data?.success) {
return result.data.value;
}
throw new Error("Failed to fetch passkeys");
},
});
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
mutationFn: async (id: string) => {
const result = await revokePasskeyAction({ id });
if (!result?.data?.success) {
throw new Error("Failed to revoke passkey");
}
},
onSuccess: () => {
toast.success("Passkey revoked successfully");
refetchPasskeys();
},
onError: () => {
toast.error("Failed to revoke passkey");
},
});
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
mutationFn: async () => {
const result = await authClient.passkey.addPasskey({
name: passkeyName || "My Passkey",
});
if (result?.error) {
throw result.error;
}
return result;
},
onSuccess: () => {
toast.success("Passkey added successfully");
setIsAddPasskeyOpen(false);
setPasskeyName("");
refetchPasskeys();
},
onError: (error: any) => {
toast.error(error.message || "Failed to add passkey");
},
});
return (
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">Security Settings</h2>
<p className="text-sm text-muted-foreground">Manage your password, two-factor authentication and
sessions.</p>
<p className="text-sm text-muted-foreground">Manage your password, two-factor authentication and sessions.</p>
</div>
<div className="space-y-6">
<h3 className="text-lg font-medium">Authentication</h3>
<div className="border rounded-lg p-4 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground">
{user.lastChangedPasswordAt
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
: "Never changed"}
{isPasswordEnabled && (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground">
{user.lastChangedPasswordAt ? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}` : "Never changed"}
</div>
</div>
{credentialAccount ? (
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} />
) : (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen} />
)}
</div>
</div>
{credentialAccount ? (
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}/>
) : (
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}/>
)}
</div>
<Separator/>
<Separator />
</>
)}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium">Two-Factor Authentication</div>
{user.twoFactorEnabled && (
<Badge variant="secondary"
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">Enhance the security of your account by
requiring a second form of verification during login.
<div className="text-sm text-muted-foreground">
Enhance the security of your account by requiring a second form of verification during login.
</div>
</div>
{user.twoFactorEnabled ? (
<div className="flex flex-col items-center gap-2">
<ViewBackupCodesModal open={isBackupCodesDialogOpen}
onOpenChange={setIsBackupCodesDialogOpen}/>
<Disable2FAProfileProviderModal open={isDisable2FADialogOpen}
onOpenChange={setIsDisable2FADialogOpen}/>
<ViewBackupCodesModal open={isBackupCodesDialogOpen} onOpenChange={setIsBackupCodesDialogOpen} />
<Disable2FAProfileProviderModal open={isDisable2FADialogOpen} onOpenChange={setIsDisable2FADialogOpen} />
</div>
) : (
<Setup2FAProfileProviderModal
disabled={!credentialAccount}
open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
<Setup2FAProfileProviderModal disabled={!credentialAccount} open={isSetup2FADialogOpen} onOpenChange={setIsSetup2FADialogOpen} />
)}
</div>
</div>
</div>
{isPasskeyEnabled && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="text-lg font-medium">Passkeys</h3>
<div className="text-sm text-muted-foreground">Login securely with your fingerprint, face recognition, or hardware key.</div>
</div>
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Passkey
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogDescription>Create a name for your passkey to identify it later.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Passkey Name</Label>
<Input
id="name"
placeholder="e.g. MacBook Pro, iPhone, YubiKey"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddPasskeyOpen(false)}>
Cancel
</Button>
<Button onClick={() => addPasskey()} disabled={isAddingPasskey}>
{isAddingPasskey && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Passkey
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-lg divide-y">
{isLoadingPasskeys ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : passkeys && passkeys.length > 0 ? (
passkeys.map((pk: any) => <PasskeyRow key={pk.id} passkey={pk} onRevoke={(id) => revokePasskey(id)} isRevoking={isRevokingPasskey} />)
) : (
<div className="p-4 text-center text-muted-foreground">No passkeys found.</div>
)}
</div>
</div>
)}
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Active Sessions</h3>
@@ -135,7 +245,7 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
onClick={() => revokeOthers()}
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
>
{isRevokingOthers && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
{isRevokingOthers && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Revoke All
</Button>
)}
@@ -161,11 +271,11 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
}
function SessionRow({
session,
onRevoke,
isRevoking,
currentSession,
}: {
session,
onRevoke,
isRevoking,
currentSession,
}: {
session: Session;
onRevoke: (token: string) => void;
isRevoking: boolean;
@@ -177,12 +287,11 @@ function SessionRow({
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<deviceInfo.Icon className="w-5 h-5"/>
<deviceInfo.Icon className="w-5 h-5" />
</div>
<div className="space-y-0.5">
<div className="text-sm font-medium flex items-center gap-2">
{deviceInfo.os} <span
className="text-muted-foreground font-normal"> {deviceInfo.browser}</span>
{deviceInfo.os} <span className="text-muted-foreground font-normal"> {deviceInfo.browser}</span>
{session.id === currentSession.id && (
<Badge
variant="outline"
@@ -193,12 +302,8 @@ function SessionRow({
)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Globe className="w-3 h-3"/> {session.ipAddress}
<span className="ml-1">
{session.id === currentSession.id
? "Active now"
: `Last active ${timeAgo(new Date(session.createdAt))}`}
</span>
<Globe className="w-3 h-3" /> {session.ipAddress}
<span className="ml-1">{session.id === currentSession.id ? "Active now" : `Last active ${timeAgo(new Date(session.createdAt))}`}</span>
</div>
</div>
</div>
@@ -211,10 +316,36 @@ function SessionRow({
onClick={() => onRevoke(session.token)}
disabled={isRevoking}
>
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin"/> : <LogOut className="w-4 h-4"/>}
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogOut className="w-4 h-4" />}
<span className="sr-only">Revoke</span>
</Button>
)}
</div>
);
}
function PasskeyRow({ passkey, onRevoke, isRevoking }: { passkey: any; onRevoke: (id: string) => void; isRevoking: boolean }) {
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<Fingerprint className="w-5 h-5" />
</div>
<div className="space-y-0.5">
<div className="font-medium text-sm">{passkey.name || "Unnamed Passkey"}</div>
<div className="text-xs text-muted-foreground">Created {timeAgo(new Date(passkey.createdAt))}</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(passkey.id)}
disabled={isRevoking}
>
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
<span className="sr-only">Revoke</span>
</Button>
</div>
);
}
@@ -0,0 +1,13 @@
CREATE TABLE "sso_provider" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"issuer" text NOT NULL,
"oidc_config" json,
"saml_config" json,
"user_id" uuid,
"provider_id" text NOT NULL,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,14 @@
ALTER TABLE "passkey" DROP CONSTRAINT "passkey_userId_user_id_fk";
--> statement-breakpoint
ALTER TABLE "passkey" ADD COLUMN "public_key" text NOT NULL;--> statement-breakpoint
ALTER TABLE "passkey" ADD COLUMN "user_id" uuid NOT NULL;--> statement-breakpoint
ALTER TABLE "passkey" ADD COLUMN "credential_i_d" text NOT NULL;--> statement-breakpoint
ALTER TABLE "passkey" ADD COLUMN "device_type" text NOT NULL;--> statement-breakpoint
ALTER TABLE "passkey" ADD COLUMN "backed_up" boolean NOT NULL;--> statement-breakpoint
ALTER TABLE "passkey" ADD COLUMN "aaguid" text;--> statement-breakpoint
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "passkey" DROP COLUMN "publicKey";--> statement-breakpoint
ALTER TABLE "passkey" DROP COLUMN "userId";--> statement-breakpoint
ALTER TABLE "passkey" DROP COLUMN "credentialId";--> statement-breakpoint
ALTER TABLE "passkey" DROP COLUMN "deviceType";--> statement-breakpoint
ALTER TABLE "passkey" DROP COLUMN "backedUp";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -239,6 +239,20 @@
"when": 1770923665015,
"tag": "0033_handy_valeria_richards",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1770991368921,
"tag": "0034_vengeful_blacklash",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1770993283219,
"tag": "0035_windy_shockwave",
"breakpoints": true
}
]
}
+41 -13
View File
@@ -1,5 +1,5 @@
import {relations} from "drizzle-orm";
import {boolean, integer, pgEnum, pgTable, text, timestamp, uuid} from "drizzle-orm/pg-core";
import {boolean, integer, pgEnum, json, pgTable, text, timestamp, uuid} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {project} from "./06_project";
@@ -72,19 +72,19 @@ export const verification = pgTable("verification", {
});
export const passkey = pgTable("passkey", {
id: uuid().defaultRandom().primaryKey(),
name: text(),
publicKey: text().notNull(),
userId: uuid()
id: uuid("id").defaultRandom().primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: uuid("user_id")
.notNull()
.references(() => user.id, {onDelete: "cascade"}),
credentialId: text().notNull(),
counter: integer().notNull(),
deviceType: text().notNull(),
backedUp: boolean().notNull(),
transports: text(),
...timestamps,
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credential_i_d").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: boolean("backed_up").notNull(),
transports: text("transports"),
aaguid: text("aaguid"),
...timestamps
});
export const twoFactor = pgTable("two_factor", {
@@ -96,11 +96,24 @@ export const twoFactor = pgTable("two_factor", {
.references(() => user.id, {onDelete: "cascade"}),
});
export const ssoProvider = pgTable("sso_provider", {
id: uuid("id").defaultRandom().primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: json("oidc_config"),
samlConfig: json("saml_config"),
userId: uuid("user_id").references(() => user.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
});
export const userRelations = relations(user, ({many}) => ({
sessions: many(session),
accounts: many(account),
ssoProviders: many(ssoProvider),
memberships: many(member),
invitations: many(invitation),
passkeys: many(passkey),
}));
export const sessionRelations = relations(session, ({one}) => ({
@@ -117,6 +130,13 @@ export const accountRelations = relations(account, ({one}) => ({
}),
}));
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
}));
export const projectRelations = relations(project, ({one}) => ({
organization: one(organization, {
fields: [project.organizationId],
@@ -124,6 +144,14 @@ export const projectRelations = relations(project, ({one}) => ({
}),
}));
export const passkeyRelations = relations(passkey, ({ one }) => ({
user: one(user, {
fields: [passkey.userId],
references: [user.id],
}),
}));
export const userSchema = createSelectSchema(user);
export type User = z.infer<typeof userSchema>;
+45 -7
View File
@@ -1,8 +1,8 @@
import {createEnv} from "@t3-oss/env-nextjs";
import {z} from "zod";
import packageJson from "../package.json" with {type: "json"};
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import packageJson from "../package.json" with { type: "json" };
const {version} = packageJson;
const { version } = packageJson;
export const env = createEnv({
server: {
@@ -28,6 +28,9 @@ export const env = createEnv({
AUTH_GOOGLE_SECRET: z.string().optional(),
AUTH_GOOGLE_METHOD: z.boolean().default(false),
AUTH_GITHUB_ID: z.string().optional(),
AUTH_GITHUB_SECRET: z.string().optional(),
S3_ENDPOINT: z.string().optional(),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
@@ -37,10 +40,25 @@ export const env = createEnv({
STORAGE_TYPE: z.enum(["local", "s3"]).optional(),
RETENTION_CRON: z
.string()
.default(process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *"),
RETENTION_CRON: z.string().default(process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *"),
AUTH_OIDC_ID: z.string().optional().default("oidc"),
AUTH_OIDC_TITLE: z.string().optional(),
AUTH_OIDC_DESC: z.string().optional(),
AUTH_OIDC_ICON: z.string().optional(),
AUTH_OIDC_CLIENT: z.string().optional(),
AUTH_OIDC_SECRET: z.string().optional(),
AUTH_OIDC_ISSUER_URL: z.string().optional(),
AUTH_OIDC_HOST: z.string().optional(),
AUTH_OIDC_SCOPES: z.string().optional(),
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
AUTH_OIDC_PKCE: z.string().optional(),
ALLOWED_GROUP: z.string().optional(),
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
AUTH_PASSKEY_ENABLED: z.string().optional().default("true"),
},
client: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
@@ -66,6 +84,9 @@ export const env = createEnv({
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
AUTH_GOOGLE_METHOD: process.env.AUTH_GOOGLE_METHOD === "true",
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
S3_ENDPOINT: process.env.S3_ENDPOINT,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
@@ -77,5 +98,22 @@ export const env = createEnv({
RETENTION_CRON: process.env.RETENTION_CRON,
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
AUTH_OIDC_DESC: process.env.AUTH_OIDC_DESC,
AUTH_OIDC_ICON: process.env.AUTH_OIDC_ICON,
AUTH_OIDC_CLIENT: process.env.AUTH_OIDC_CLIENT,
AUTH_OIDC_SECRET: process.env.AUTH_OIDC_SECRET,
AUTH_OIDC_ISSUER_URL: process.env.AUTH_OIDC_ISSUER_URL,
AUTH_OIDC_HOST: process.env.AUTH_OIDC_HOST,
AUTH_OIDC_SCOPES: process.env.AUTH_OIDC_SCOPES,
AUTH_OIDC_DISCOVERY_ENDPOINT: process.env.AUTH_OIDC_DISCOVERY_ENDPOINT,
AUTH_OIDC_JWKS_ENDPOINT: process.env.AUTH_OIDC_JWKS_ENDPOINT,
AUTH_OIDC_PKCE: process.env.AUTH_OIDC_PKCE,
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
},
});
+6 -2
View File
@@ -3,8 +3,10 @@ import {createAuthClient} from "better-auth/react";
import {adminClient, inferAdditionalFields, organizationClient, twoFactorClient} from "better-auth/client/plugins";
import {ac, user, admin as adminRole, pending, superadmin, orgAdmin, orgMember, orgOwner} from "./permissions";
import {auth} from "@/lib/auth/auth";
import type {auth} from "@/lib/auth/auth";
import {getServerUrl} from "@/utils/get-server-url";
import { ssoClient } from "@better-auth/sso/client";
import { passkeyClient } from "@better-auth/passkey/client"
const res = await fetch(`${getServerUrl()}/api/config`);
const {PROJECT_URL} = await res.json();
@@ -12,7 +14,9 @@ const {PROJECT_URL} = await res.json();
export const authClient = createAuthClient({
baseURL: PROJECT_URL,
plugins: [
passkeyClient(),
twoFactorClient(),
ssoClient(),
organizationClient({
ac,
roles: {
@@ -35,4 +39,4 @@ export const authClient = createAuthClient({
});
export const {signIn, signOut, signUp, useSession, listAccounts, admin, requestPasswordReset} = authClient;
export const { signIn, signOut, signUp, deleteUser, useSession, listAccounts, passkey, admin, twoFactor, requestPasswordReset, sso } = authClient;
+103 -7
View File
@@ -11,12 +11,14 @@ import {count, eq} from "drizzle-orm";
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {sendEmail} from "@/lib/email";
import {render} from "@react-email/render";
import {AuthProviderConfig, SUPPORTED_PROVIDERS} from "../../../portabase.config";
import {withUpdatedAt} from "@/db/utils";
import EmailVerification from "@/components/emails/auth/email-verification";
import EmailForgotPassword from "@/components/emails/auth/email-forgot-password";
import {getDeviceDetails} from "@/utils/detection";
import EmailNewLogin from "@/components/emails/auth/email-new-login";
import { sso } from "@better-auth/sso";
import { AuthProviderConfig, SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { passkey } from "@better-auth/passkey";
export const auth = betterAuth({
database: drizzleAdapter(db, {
@@ -27,11 +29,11 @@ export const auth = betterAuth({
baseURL: env.PROJECT_URL,
secret: env.PROJECT_SECRET,
emailAndPassword: {
enabled: true,
enabled: env.AUTH_EMAIL_PASSWORD_ENABLED === "true",
requireEmailVerification: false,
sendResetPassword: async ({user, token}, request) => {
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({
await db.update(drizzleDb.schemas.user).set(withUpdatedAt({
emailVerified: true,
})).where(eq(drizzleDb.schemas.user.id, user.id)).returning();
@@ -78,6 +80,7 @@ export const auth = betterAuth({
socialProviders: SUPPORTED_PROVIDERS.reduce((acc: any, provider: AuthProviderConfig) => {
if (!provider.isActive) return acc;
if (provider.id === "credential") return acc;
if (provider.id === env.AUTH_OIDC_ID!) return acc;
if (provider.id === "google") {
acc.google = {
clientId: env.AUTH_GOOGLE_ID! as string,
@@ -86,8 +89,8 @@ export const auth = betterAuth({
}
if (provider.id === "github") {
acc.github = {
clientId: provider.credentials?.clientId,
clientSecret: provider.credentials?.clientSecret,
// clientId: provider.credentials?.clientId,
// clientSecret: provider.credentials?.clientSecret,
};
}
return acc;
@@ -95,12 +98,69 @@ export const auth = betterAuth({
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github", "credential"],
trustedProviders: ["google", "github", "credential",env.AUTH_OIDC_ID!],
allowDifferentEmails: false
},
},
plugins: [
sso({
defaultSSO: [{
oidcConfig: {
issuer: env.AUTH_OIDC_ISSUER_URL!,
discoveryEndpoint: env.AUTH_OIDC_DISCOVERY_ENDPOINT!,
jwksEndpoint: env.AUTH_OIDC_JWKS_ENDPOINT!,
clientId: env.AUTH_OIDC_CLIENT!,
clientSecret: env.AUTH_OIDC_SECRET!,
scopes: env.AUTH_OIDC_SCOPES?.split(" ") ?? ["openid", "profile", "email"],
pkce: env.AUTH_OIDC_PKCE === "true",
mapping: {
extraFields: {
groups: "groups"
}
}
},
providerId: env.AUTH_OIDC_ID!,
domain: env.AUTH_OIDC_HOST!,
//@ts-ignore
issuer: env.AUTH_OIDC_ISSUER_URL!
}],
provisionUser: async ({ user: usr, userInfo }) => {
const allowedGroup = env.ALLOWED_GROUP;
if (!allowedGroup) return;
const rawGroups = (userInfo as any).groups || (userInfo as any).roles || [];
const userGroups: string[] = Array.isArray(rawGroups) ? rawGroups : [rawGroups];
const hasAccess = userGroups.includes(allowedGroup);
if (!hasAccess) {
throw new Error("Access Denied");
}
const userCount = (await db.select({ count: count() }).from(drizzleDb.schemas.user))[0].count;
const isSuperadmin = userCount === 0 ? "superadmin" : undefined;
const roleToAssign = allowedGroup.includes('admin') || allowedGroup.includes('superadmin') ?
isSuperadmin ? 'superadmin' : "admin" : 'pending';
const existingUser = await db.query.user.findFirst({
where: eq(drizzleDb.schemas.user.email, usr.email)
});
if (existingUser) {
await db.update(drizzleDb.schemas.user)
.set({ role: roleToAssign, emailVerified: true })
.where(eq(drizzleDb.schemas.user.id, existingUser.id));
}
},
}),
...(env.AUTH_PASSKEY_ENABLED === "true" ? [passkey({
rpName: env.PROJECT_NAME || "Portabase",
rpID: env.PROJECT_URL ? new URL(env.PROJECT_URL).hostname : "localhost"
})] : []),
openAPI(),
nextCookies(),
twoFactor(),
@@ -155,11 +215,28 @@ export const auth = betterAuth({
},
databaseHooks: {
user: {
update: {
async before(user, context) {
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
if (user.password || user.lastChangedPasswordAt) {
throw new Error("Password updates are disabled");
}
}
return {
data: user,
};
},
},
create: {
async before(user, context) {
const userCount = (await db.select({count: count()}).from(drizzleDb.schemas.user))[0].count;
if (env.AUTH_SIGNUP_ENABLED !== "true" && userCount > 0) {
throw new Error("Sign up is disabled");
}
const role = userCount === 0 ? "superadmin" : "pending";
// const role = "admin";
return {
data: {
...user,
@@ -417,6 +494,25 @@ export const getOrganization = async ({
}
};
export const getPasskeys = async () => {
if (env.AUTH_PASSKEY_ENABLED !== "true") return [];
const passkeys = await auth.api.listPasskeys({
headers: await headers(),
});
return passkeys;
};
export const revokePasskey = async (e: string) => {
if (env.AUTH_PASSKEY_ENABLED !== "true") return;
await auth.api.deletePasskey({
body: {
id: e,
},
headers: await headers(),
});
};
export const listOrganizations = async (): Promise<Organization[] | null> => {
try {
return await auth.api.listOrganizations({
+63
View File
@@ -0,0 +1,63 @@
import { env } from "@/env.mjs";
export interface AuthProviderConfig {
id: string;
isActive: boolean;
name?: string;
icon: string;
isManual?: boolean;
title?: string;
description?: string;
type: "social" | "sso" | "credential" | "passkey";
}
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
{
id: "credential",
isActive: env.AUTH_EMAIL_PASSWORD_ENABLED === "true",
name: "Password",
icon: "lucide:lock",
title: "Password",
description: "Standard email and password login.",
isManual: true,
type: "credential"
},
{
id: "google",
isActive: !!env.AUTH_GOOGLE_ID,
name: "Google",
icon: "logos:google-icon",
title: "Google",
description: "Sign in with your Google account.",
type: "social"
},
{
id: "github",
isActive: !!env.AUTH_GITHUB_ID,
name: "GitHub",
icon: "logos:github-icon",
title: "GitHub",
description: "Sign in with your GitHub account.",
type: "social"
},
{
id: env.AUTH_OIDC_ID || "oidc",
isActive: !!env.AUTH_OIDC_CLIENT,
name: env.AUTH_OIDC_TITLE || "SSO",
icon: env.AUTH_OIDC_ICON || "lucide:building",
title: env.AUTH_OIDC_TITLE || "SSO",
description: env.AUTH_OIDC_DESC || "Sign in with your SSO account.",
isManual: true,
type: "sso"
},
{
id: "passkey",
isActive: env.AUTH_PASSKEY_ENABLED === "true",
name: "Passkey",
icon: "lucide:fingerprint",
title: "Passkey",
description: "Sign in with your passkey.",
isManual: false,
type: "passkey"
}
];