Merge branch 'dev' into feat/oidc

This commit is contained in:
Théo LAGACHE
2026-02-24 16:49:16 +01:00
35 changed files with 1191 additions and 3598 deletions
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.2.9
date-released: "2026-02-19"
version: 1.3.0
date-released: "2026-02-24"
+39 -32
View File
@@ -10,42 +10,49 @@ import { CardAuth } from "@/features/layout/card-auth";
import { env } from "@/env.mjs";
export const metadata: Metadata = {
title: "Login",
title: "Login",
};
export default async function SignInPage() {
return (
<TooltipProvider>
<CardAuth className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">Fill your login informations</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && <LoginForm isPasskeyEnabled={env.AUTH_PASSKEY_ENABLED === "true"} />}
return (
<TooltipProvider>
<CardAuth className="w-full">
<CardHeader>
<div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">
Fill your login informations
</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
<LoginForm isPasskeyEnabled={env.AUTH_PASSKEY_ENABLED === "true"} />
)}
{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>
)}
{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>
)}
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && <SocialAuthButtons providers={SUPPORTED_PROVIDERS} />}
{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>
);
{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>
);
}
@@ -4,6 +4,7 @@ import {db} from "@/db";
import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
export default async function RoutePage(props: PageParams<{}>) {
@@ -21,6 +22,9 @@ export default async function RoutePage(props: PageParams<{}>) {
},
});
const credentialProvider = SUPPORTED_PROVIDERS.find(p => p.id === 'credential');
const isPasswordAuthEnabled = credentialProvider?.isActive || false;
return (
<Page>
<PageHeader className="flex flex-col">
@@ -32,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<AdminUserList users={users}/>
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
</PageContent>
</Page>
);
+6 -6
View File
@@ -1,9 +1,9 @@
import {NextResponse} from "next/server";
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
});
return NextResponse.json({
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
});
}
+32
View File
@@ -0,0 +1,32 @@
name: portabase-dev-func
services:
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
pocket-id:
image: ghcr.io/pocket-id/pocket-id
restart: unless-stopped
environment:
- APP_URL=http://localhost:3055
- ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr
ports:
- 3055:1411
volumes:
- pocket-id-data:/app/data
healthcheck:
test: "curl -f http://localhost:1411/healthz"
interval: 1m30s
timeout: 5s
retries: 2
start_period: 10s
volumes:
keycloak-data:
pocket-id-data:
-28
View File
@@ -32,33 +32,5 @@ services:
- ./private/uploads/tmp:/data/uploads/tmp
user: "1000:1000"
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
pocket-id:
image: ghcr.io/pocket-id/pocket-id
restart: unless-stopped
environment:
- APP_URL=http://localhost:3055
- ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr
ports:
- 3055:1411
volumes:
- pocket-id-data:/app/data
healthcheck:
test: "curl -f http://localhost:1411/healthz"
interval: 1m30s
timeout: 5s
retries: 2
start_period: 10s
volumes:
postgres-data:
keycloak-data:
pocket-id-data:
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.2.9",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -129,5 +129,5 @@
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+10 -4
View File
@@ -6,7 +6,7 @@ import {env} from "@/env.mjs";
interface EmailCreateUserProps {
email: string;
password: string;
password?: string;
}
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
@@ -23,9 +23,15 @@ export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
<strong>Email: </strong>{email}
</Text>
<Text className="text-[14px] text-black leading-[24px]">
<strong>Default password: </strong>{password}
</Text>
{password ? (
<Text className="text-[14px] text-black leading-[24px]">
<strong>Default password: </strong>{password}
</Text>
) : (
<Text className="text-[14px] text-black leading-[24px]">
You can log in using one of the single sign-on (SSO) providers configured by your administrator.
</Text>
)}
<Section className="mt-[32px] mb-[32px] text-center">
<Button
@@ -39,7 +39,8 @@ export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig
});
} else {
result = await authClient.signIn.social({
provider: provider.id as "google" | "github",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: provider.id as any,
callbackURL: "/dashboard",
});
}
@@ -5,8 +5,9 @@ import {User} from "@/db/schema/02_user";
type AdminUserListProps = {
users: User[];
isPasswordAuthEnabled: boolean;
};
export const AdminUserList = ({ users }: AdminUserListProps) => {
return <DataTable columns={usersListColumns()} data={users} enablePagination={true} enableSelect={false} />;
export const AdminUserList = ({ users, isPasswordAuthEnabled }: AdminUserListProps) => {
return <DataTable columns={usersListColumns({ isPasswordAuthEnabled })} data={users} enablePagination={true} enableSelect={false} />;
};
@@ -9,7 +9,11 @@ import {Info} from "lucide-react";
import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table";
import {UserActionsCell} from "@/components/wrappers/dashboard/admin/users/user-actions-cell";
export function usersListColumns(): ColumnDef<User>[] {
type UsersListColumnsProps = {
isPasswordAuthEnabled: boolean;
}
export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProps): ColumnDef<User>[] {
return [
{
@@ -85,7 +89,7 @@ export function usersListColumns(): ColumnDef<User>[] {
{
id: "actions",
header: "Actions",
cell: ({row}) => <UserActionsCell user={row.original}/>,
cell: ({row}) => <UserActionsCell user={row.original} isPasswordAuthEnabled={isPasswordAuthEnabled} />,
},
];
}
@@ -20,9 +20,10 @@ import {AdminDeleteUserModal} from "@/components/wrappers/dashboard/admin/users/
interface UserActionsCellProps {
user: User;
isPasswordAuthEnabled: boolean;
}
export function UserActionsCell({user}: UserActionsCellProps) {
export function UserActionsCell({user, isPasswordAuthEnabled}: UserActionsCellProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false);
@@ -43,9 +44,11 @@ export function UserActionsCell({user}: UserActionsCellProps) {
onOpenChange={setIsModalChangePasswordOpen}/>
<AdminUserEdit user={user} open={isModalEditUserOpen} onOpenChange={setIsModalEditUserOpen}/>
<div className={cn("flex items-center space-x-2")}>
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
<RotateCcwKey className="w-4 h-4"/>
</Button>
{isPasswordAuthEnabled && (
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
<RotateCcwKey className="w-4 h-4"/>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
@@ -1,191 +1,211 @@
"use server";
import * as drizzleDb from "@/db";
import {ServerActionResult} from "@/types/action-type";
import {render} from "@react-email/render";
import {UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema";
import {extractNameFromEmail} from "@/utils/name-from-email";
import {generateValidPassword} from "@/utils/password";
import {auth} from "@/lib/auth/auth";
import {z} from "zod";
import {Organization} from "@/db/schema/03_organization";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import { ServerActionResult } from "@/types/action-type";
import { render } from "@react-email/render";
import { UserSchema } from "@/components/wrappers/dashboard/admin/users/user.schema";
import { extractNameFromEmail } from "@/utils/name-from-email";
import { generateValidPassword } from "@/utils/password";
import { auth } from "@/lib/auth/auth";
import { z } from "zod";
import { Organization } from "@/db/schema/03_organization";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import {zEmail, zString} from "@/lib/zod";
import {withUpdatedAt} from "@/db/utils";
import {userAction} from "@/lib/safe-actions/actions";
import {
addMemberOrganizationAction
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
import {sendEmail} from "@/lib/email";
import { zEmail, zString } from "@/lib/zod";
import { withUpdatedAt } from "@/db/utils";
import { userAction } from "@/lib/safe-actions/actions";
import { addMemberOrganizationAction } from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
import { sendEmail } from "@/lib/email";
import EmailCreateUser from "@/components/emails/email-create-user";
import {SignUpUser} from "@/types/auth";
import {createUserDb} from "@/db/services/user";
import {User} from "@/db/schema/02_user";
import { SignUpUser } from "@/types/auth";
import { createUserDb } from "@/db/services/user";
import { User } from "@/db/schema/02_user";
import { env } from "@/env.mjs";
export const createUserAction = userAction.schema(UserSchema).action(async ({parsedInput}): Promise<ServerActionResult<User>> => {
export const createUserAction = userAction
.schema(UserSchema)
.action(async ({ parsedInput }): Promise<ServerActionResult<User>> => {
try {
const password = generateValidPassword();
const isPasswordAuthEnabled = env.AUTH_EMAIL_PASSWORD_ENABLED === "true";
let password;
const userData: SignUpUser = {
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
email: parsedInput.email,
password: password,
theme: "dark",
role: "user",
};
const userData: SignUpUser = {
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
email: parsedInput.email,
theme: "dark",
role: "user",
};
const newUser = await createUserDb(userData);
if (isPasswordAuthEnabled) {
password = generateValidPassword();
userData.password = password;
}
if (newUser) {
const newUser = await createUserDb(userData);
await sendEmail({
to: parsedInput.email,
subject: "Your account is created",
html: await render(EmailCreateUser({
password: password,
email: parsedInput.email,
})),
});
if (newUser) {
await sendEmail({
to: parsedInput.email,
subject: "Your account is created",
html: await render(
EmailCreateUser({
password: password,
email: parsedInput.email,
}),
),
});
const defaultOrganization = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, "default"),
});
const defaultOrganization = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, "default"),
});
if (defaultOrganization) {
await auth.api.addMember({
body: {
userId: newUser.id,
organizationId: defaultOrganization.id,
role: "admin",
},
});
}
return {
success: true,
value: newUser,
actionSuccess: {
message: "user_created",
},
};
if (defaultOrganization) {
await auth.api.addMember({
body: {
userId: newUser.id,
organizationId: defaultOrganization.id,
role: "admin",
},
});
}
return {
success: false,
actionError: {
message: "user_created",
cause: "Unknown error",
},
success: true,
value: newUser,
actionSuccess: {
message: "user_created",
},
};
}
return {
success: false,
actionError: {
message: "user_created",
cause: "Unknown error",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "user_created",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
return {
success: false,
actionError: {
message: "user_created",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
});
export const updateUserAction = userAction
.schema(
z.object({
id: zString(),
name: zString().optional(),
email: zEmail(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<{}>> => {
try {
.schema(
z.object({
id: zString(),
name: zString().optional(),
email: zEmail(),
}),
)
.action(async ({ parsedInput, ctx }): Promise<ServerActionResult<{}>> => {
try {
const [updatedUser] = await db
.update(drizzleDb.schemas.user)
.set(
withUpdatedAt({
name: parsedInput.name
? parsedInput.name
: extractNameFromEmail(parsedInput.email),
email: parsedInput.email,
emailVerified: false,
}),
)
.where(eq(drizzleDb.schemas.user.id, parsedInput.id))
.returning();
if (updatedUser) {
return {
success: true,
actionSuccess: {
message: "user_updated",
},
};
}
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({
name: parsedInput.name ? parsedInput.name : extractNameFromEmail(parsedInput.email),
email: parsedInput.email,
emailVerified: false
})).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning();
if (updatedUser) {
return {
success: true,
actionSuccess: {
message: "user_updated",
},
};
}
return {
success: false,
actionError: {
message: "user_updated",
cause: "Unknown error",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "user_updated",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
return {
success: false,
actionError: {
message: "user_updated",
cause: "Unknown error",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "user_updated",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const setSuperAdminOwnerOfOrganizationsOwnedByUser = userAction
.schema(
z.object({
userId: z.string(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Organization[]>> => {
try {
const organizationsWhereUserIsMemberAndOwner = await db.query.member.findMany({
where: and(eq(drizzleDb.schemas.member.role, "owner"), eq(drizzleDb.schemas.member.userId, parsedInput.userId)),
with: {
organization: true,
},
});
.schema(
z.object({
userId: z.string(),
}),
)
.action(
async ({ parsedInput }): Promise<ServerActionResult<Organization[]>> => {
try {
const organizationsWhereUserIsMemberAndOwner =
await db.query.member.findMany({
where: and(
eq(drizzleDb.schemas.member.role, "owner"),
eq(drizzleDb.schemas.member.userId, parsedInput.userId),
),
with: {
organization: true,
},
});
const superAdminUser = await db.query.user.findFirst();
if (!superAdminUser) {
return {
success: false,
actionError: {
message: "set_super_admin_owner_of_organizations_owned_by_user",
cause: "Unknown error",
},
};
}
for (let {organization} of organizationsWhereUserIsMemberAndOwner) {
await addMemberOrganizationAction({
userId: superAdminUser.id,
organizationId: organization.id,
role: "owner",
});
}
const organizations = organizationsWhereUserIsMemberAndOwner.map(
(organizationWhereUserIsMemberAndOwner) => organizationWhereUserIsMemberAndOwner.organization
);
return {
success: true,
value: organizations as unknown as Organization[],
actionSuccess: {
message: "set_super_admin_owner_of_organizations_owned_by_user",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_set_super_admin_owner_of_organizations_owned_by_user",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
const superAdminUser = await db.query.user.findFirst();
if (!superAdminUser) {
return {
success: false,
actionError: {
message: "set_super_admin_owner_of_organizations_owned_by_user",
cause: "Unknown error",
},
};
}
});
for (let { organization } of organizationsWhereUserIsMemberAndOwner) {
await addMemberOrganizationAction({
userId: superAdminUser.id,
organizationId: organization.id,
role: "owner",
});
}
const organizations = organizationsWhereUserIsMemberAndOwner.map(
(organizationWhereUserIsMemberAndOwner) =>
organizationWhereUserIsMemberAndOwner.organization,
);
return {
success: true,
value: organizations as unknown as Organization[],
actionSuccess: {
message: "set_super_admin_owner_of_organizations_owned_by_user",
},
};
} catch (error) {
return {
success: false,
actionError: {
message:
"error_set_super_admin_owner_of_organizations_owned_by_user",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
},
);
@@ -8,7 +8,11 @@ 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 {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
import { Icon } from "@iconify/react";
@@ -17,164 +21,217 @@ import type { AuthProviderConfig } from "@/lib/auth/config";
import { Account } from "@/db/schema/02_user";
interface ProfileProviderProps {
accounts: Account[];
providers: AuthProviderConfig[];
accounts: Account[];
providers: AuthProviderConfig[];
}
export function ProfileProviders({ accounts, providers }: ProfileProviderProps) {
const router = useRouter();
const totalConnected = accounts.length;
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
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 [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const { mutate: unlinkAccount } = useMutation({
mutationFn: async (providerId: string) => {
setLoadingProvider(providerId);
const { error } = await authClient.unlinkAccount({ providerId });
if (error) throw error;
},
onSuccess: () => {
toast.success("Provider successfully unlinked!");
setLoadingProvider(null);
router.refresh();
},
onError: () => {
toast.error("An error occurred while unlinking provider.");
setLoadingProvider(null);
},
});
const { mutate: unlinkAccount } = useMutation({
mutationFn: async (providerId: string) => {
setLoadingProvider(providerId);
const { error } = await authClient.unlinkAccount({ providerId });
if (error) throw error;
},
onSuccess: () => {
toast.success("Provider successfully unlinked!");
setLoadingProvider(null);
router.refresh();
},
onError: () => {
toast.error("An error occurred while unlinking provider.");
setLoadingProvider(null);
},
});
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",
});
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 any,
callbackURL: "/dashboard",
});
}
if (result.error) throw result.error;
},
onSuccess: () => {
setLoadingProvider(null);
router.refresh();
},
onError: () => {
toast.error("An error occurred while linking provider.");
setLoadingProvider(null);
},
});
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;
const isUnlinkDisabled = !canUnlink || provider.allowUnlinking === false;
const unlinkButton = (
<Button
variant="outline"
size="sm"
onClick={() => unlinkAccount(provider.id)}
disabled={isUnlinkDisabled || isLoading || provider.isManual}
className={isUnlinkDisabled ? "opacity-50 cursor-not-allowed" : ""}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
</Button>
);
let actionElement;
if (!isConnected) {
actionElement =
provider.id === "credential" ? (
<SetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
) : (
<Button
variant="default"
size="sm"
onClick={() => linkAccount(provider)}
disabled={
isLoading || provider.isManual || provider.allowLinking === false
}
if (result.error) throw result.error;
},
onSuccess: () => {
setLoadingProvider(null);
router.refresh();
},
onError: () => {
toast.error("An error occurred while linking provider.");
setLoadingProvider(null);
},
});
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 || provider.allowUnlinking === false}
className={!canUnlink || provider.allowUnlinking === false ? "opacity-50 cursor-not-allowed" : ""}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
</Button>
</span>
</TooltipTrigger>
{(!canUnlink || provider.allowUnlinking === false) && (
<TooltipContent>
<p>{provider.allowUnlinking === false ? "Unlinking is disabled for this provider." : "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 || provider.allowLinking === false}>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"}
</Button>
)}
</>
)}
</div>
</div>
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"}
</Button>
);
};
} else if (isUnlinkDisabled) {
actionElement = (
<Tooltip>
<TooltipTrigger asChild>
<div tabIndex={0} className="inline-block">
{unlinkButton}
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{provider.allowUnlinking === false
? "Unlinking is disabled for this provider."
: "You cannot unlink your last authentication provider."}
</p>
</TooltipContent>
</Tooltip>
);
} else {
actionElement = unlinkButton;
}
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">Authentication</h2>
<p className="text-sm text-muted-foreground">Manage how you access your account.</p>
</div>
{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>
<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 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>
<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>
<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.</AlertDescription>
</Alert>
<div className="text-sm text-muted-foreground">
{provider.description}
</div>
</div>
</div>
<div className="flex items-center gap-2">{actionElement}</div>
</div>
);
};
return (
<div className="space-y-8 animate-in fade-in-50 duration-300 pb-10">
<div className="mb-6 space-y-1">
<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>
{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>
)}
<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.
</AlertDescription>
</Alert>
</div>
);
}
@@ -162,77 +162,81 @@ export function ProfileSecurity({
</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">
{isPasswordEnabled && (
<>
{isPasskeyEnabled && (
<div className="space-y-6">
<h3 className="text-lg font-medium">Authentication</h3>
<div className="border rounded-lg p-4 space-y-4">
{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>
<Separator />
</>
)}
{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="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"
>
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
{user.lastChangedPasswordAt
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
: "Never changed"}
Enhance the security of your account by requiring a second
form of verification during login.
</div>
</div>
{credentialAccount ? (
<ResetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
{user.twoFactorEnabled ? (
<div className="flex flex-col items-center gap-2">
<ViewBackupCodesModal
open={isBackupCodesDialogOpen}
onOpenChange={setIsBackupCodesDialogOpen}
/>
<Disable2FAProfileProviderModal
open={isDisable2FADialogOpen}
onOpenChange={setIsDisable2FADialogOpen}
/>
</div>
) : (
<SetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
<Setup2FAProfileProviderModal
disabled={!credentialAccount}
open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
)}
</div>
<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"
>
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>
</div>
{user.twoFactorEnabled ? (
<div className="flex flex-col items-center gap-2">
<ViewBackupCodesModal
open={isBackupCodesDialogOpen}
onOpenChange={setIsBackupCodesDialogOpen}
/>
<Disable2FAProfileProviderModal
open={isDisable2FADialogOpen}
onOpenChange={setIsDisable2FADialogOpen}
/>
</div>
) : (
<Setup2FAProfileProviderModal
disabled={!credentialAccount}
open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
)}
</div>
</div>
</div>
)}
{isPasskeyEnabled && (
<div className="space-y-6">
@@ -377,20 +381,21 @@ function SessionRow({
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground relative">
<deviceInfo.Icon className="w-5 h-5" />
{provider && (
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
{provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id}
width={12}
height={12}
className="w-3 h-3"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="w-3 h-3" />
)}
</div>
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
{provider.icon.startsWith("/") ||
provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id}
width={12}
height={12}
className="w-3 h-3"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="w-3 h-3" />
)}
</div>
)}
</div>
<div className="space-y-0.5">
@@ -400,9 +405,9 @@ function SessionRow({
{deviceInfo.browser}
</span>
{provider && (
<span className="text-muted-foreground font-normal">
{provider.title || provider.name}
</span>
<span className="text-muted-foreground font-normal">
{provider.title || provider.name}
</span>
)}
{session.id === currentSession.id && (
<Badge
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'sqlite';
@@ -1,13 +0,0 @@
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;
@@ -1,11 +1,25 @@
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 "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 "credential_id" 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 "session" ADD COLUMN "provider_id" text;--> 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;--> 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
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "session" ADD COLUMN "provider_id" text;
+76 -326
View File
@@ -1,5 +1,5 @@
{
"id": "9106b694-50a6-4b6b-b33a-26d958844097",
"id": "90c26bd4-552c-445a-8491-487bcf705fa6",
"prevId": "29b53ca7-d603-4433-96f0-44d2df48ebdc",
"version": "7",
"dialect": "postgresql",
@@ -96,12 +96,8 @@
"name": "settings_default_storage_channel_id_storage_channel_id_fk",
"tableFrom": "settings",
"tableTo": "storage_channel",
"columnsFrom": [
"default_storage_channel_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["default_storage_channel_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -111,9 +107,7 @@
"settings_name_unique": {
"name": "settings_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
"columns": ["name"]
}
},
"policies": {},
@@ -217,12 +211,8 @@
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -318,12 +308,8 @@
"name": "passkey_userId_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -413,12 +399,8 @@
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -428,93 +410,7 @@
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sso_provider": {
"name": "sso_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"issuer": {
"name": "issuer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_config": {
"name": "oidc_config",
"type": "json",
"primaryKey": false,
"notNull": false
},
"saml_config": {
"name": "saml_config",
"type": "json",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sso_provider_user_id_user_id_fk": {
"name": "sso_provider_user_id_user_id_fk",
"tableFrom": "sso_provider",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sso_provider_provider_id_unique": {
"name": "sso_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
"columns": ["token"]
}
},
"policies": {},
@@ -557,12 +453,8 @@
"name": "two_factor_user_id_user_id_fk",
"tableFrom": "two_factor",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -686,9 +578,7 @@
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@@ -814,9 +704,7 @@
"organization_slug_unique": {
"name": "organization_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
"columns": ["slug"]
}
},
"policies": {},
@@ -878,12 +766,8 @@
"name": "member_organization_id_organization_id_fk",
"tableFrom": "member",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -891,12 +775,8 @@
"name": "member_user_id_user_id_fk",
"tableFrom": "member",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -980,12 +860,8 @@
"name": "invitation_organization_id_organization_id_fk",
"tableFrom": "invitation",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -993,12 +869,8 @@
"name": "invitation_inviter_id_user_id_fk",
"tableFrom": "invitation",
"tableTo": "user",
"columnsFrom": [
"inviter_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["inviter_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1071,12 +943,8 @@
"name": "projects_organization_id_organization_id_fk",
"tableFrom": "projects",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1086,9 +954,7 @@
"projects_slug_unique": {
"name": "projects_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
"columns": ["slug"]
}
},
"policies": {},
@@ -1165,12 +1031,8 @@
"name": "backups_database_id_databases_id_fk",
"tableFrom": "backups",
"tableTo": "databases",
"columnsFrom": [
"database_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["database_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1281,12 +1143,8 @@
"name": "databases_agent_id_agents_id_fk",
"tableFrom": "databases",
"tableTo": "agents",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1294,12 +1152,8 @@
"name": "databases_project_id_projects_id_fk",
"tableFrom": "databases",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -1373,12 +1227,8 @@
"name": "restorations_backup_storage_id_backup_storage_id_fk",
"tableFrom": "restorations",
"tableTo": "backup_storage",
"columnsFrom": [
"backup_storage_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["backup_storage_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1386,12 +1236,8 @@
"name": "restorations_backup_id_backups_id_fk",
"tableFrom": "restorations",
"tableTo": "backups",
"columnsFrom": [
"backup_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["backup_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1399,12 +1245,8 @@
"name": "restorations_database_id_databases_id_fk",
"tableFrom": "restorations",
"tableTo": "databases",
"columnsFrom": [
"database_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["database_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1507,12 +1349,8 @@
"name": "retention_policies_database_id_databases_id_fk",
"tableFrom": "retention_policies",
"tableTo": "databases",
"columnsFrom": [
"database_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["database_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1598,9 +1436,7 @@
"agents_slug_unique": {
"name": "agents_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
"columns": ["slug"]
}
},
"policies": {},
@@ -1676,12 +1512,8 @@
"name": "notification_channel_organization_id_organization_id_fk",
"tableFrom": "notification_channel",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1715,12 +1547,8 @@
"name": "organization_notification_channels_organization_id_organization_id_fk",
"tableFrom": "organization_notification_channels",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1728,12 +1556,8 @@
"name": "organization_notification_channels_notification_channel_id_notification_channel_id_fk",
"tableFrom": "organization_notification_channels",
"tableTo": "notification_channel",
"columnsFrom": [
"notification_channel_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["notification_channel_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1743,10 +1567,7 @@
"organization_notification_channels_organization_id_notification_channel_id_unique": {
"name": "organization_notification_channels_organization_id_notification_channel_id_unique",
"nullsNotDistinct": false,
"columns": [
"organization_id",
"notification_channel_id"
]
"columns": ["organization_id", "notification_channel_id"]
}
},
"policies": {},
@@ -1816,12 +1637,8 @@
"name": "alert_policy_notification_channel_id_notification_channel_id_fk",
"tableFrom": "alert_policy",
"tableTo": "notification_channel",
"columnsFrom": [
"notification_channel_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["notification_channel_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1829,12 +1646,8 @@
"name": "alert_policy_database_id_databases_id_fk",
"tableFrom": "alert_policy",
"tableTo": "databases",
"columnsFrom": [
"database_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["database_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1993,12 +1806,8 @@
"name": "organization_storage_channels_organization_id_organization_id_fk",
"tableFrom": "organization_storage_channels",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -2006,12 +1815,8 @@
"name": "organization_storage_channels_storage_channel_id_storage_channel_id_fk",
"tableFrom": "organization_storage_channels",
"tableTo": "storage_channel",
"columnsFrom": [
"storage_channel_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["storage_channel_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2021,10 +1826,7 @@
"organization_storage_channels_organization_id_storage_channel_id_unique": {
"name": "organization_storage_channels_organization_id_storage_channel_id_unique",
"nullsNotDistinct": false,
"columns": [
"organization_id",
"storage_channel_id"
]
"columns": ["organization_id", "storage_channel_id"]
}
},
"policies": {},
@@ -2100,12 +1902,8 @@
"name": "storage_channel_organization_id_organization_id_fk",
"tableFrom": "storage_channel",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2172,12 +1970,8 @@
"name": "storage_policy_storage_channel_id_storage_channel_id_fk",
"tableFrom": "storage_policy",
"tableTo": "storage_channel",
"columnsFrom": [
"storage_channel_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["storage_channel_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -2185,12 +1979,8 @@
"name": "storage_policy_database_id_databases_id_fk",
"tableFrom": "storage_policy",
"tableTo": "databases",
"columnsFrom": [
"database_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["database_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2276,12 +2066,8 @@
"name": "backup_storage_backup_id_backups_id_fk",
"tableFrom": "backup_storage",
"tableTo": "backups",
"columnsFrom": [
"backup_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["backup_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -2289,12 +2075,8 @@
"name": "backup_storage_storage_channel_id_storage_channel_id_fk",
"tableFrom": "backup_storage",
"tableTo": "storage_channel",
"columnsFrom": [
"storage_channel_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["storage_channel_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2310,20 +2092,12 @@
"public.user_themes": {
"name": "user_themes",
"schema": "public",
"values": [
"light",
"dark",
"system"
]
"values": ["light", "dark", "system"]
},
"public.retention_policy_type": {
"name": "retention_policy_type",
"schema": "public",
"values": [
"count",
"days",
"gfs"
]
"values": ["count", "days", "gfs"]
},
"public.provider_kind": {
"name": "provider_kind",
@@ -2352,56 +2126,32 @@
"public.level": {
"name": "level",
"schema": "public",
"values": [
"critical",
"warning",
"info"
]
"values": ["critical", "warning", "info"]
},
"public.provider_storage_kind": {
"name": "provider_storage_kind",
"schema": "public",
"values": [
"local",
"s3",
"google-drive"
]
"values": ["local", "s3", "google-drive"]
},
"public.backup_storage_status": {
"name": "backup_storage_status",
"schema": "public",
"values": [
"pending",
"success",
"failed"
]
"values": ["pending", "success", "failed"]
},
"public.dbms_status": {
"name": "dbms_status",
"schema": "public",
"values": [
"postgresql",
"mysql",
"mongodb"
]
"values": ["postgresql", "mysql", "mongodb", "sqlite"]
},
"public.status": {
"name": "status",
"schema": "public",
"values": [
"waiting",
"ongoing",
"failed",
"success"
]
"values": ["waiting", "ongoing", "failed", "success"]
},
"public.type_storage": {
"name": "type_storage",
"schema": "public",
"values": [
"local",
"s3"
]
"values": ["local", "s3"]
}
},
"schemas": {},
@@ -2414,4 +2164,4 @@
"schemas": {},
"tables": {}
}
}
}
+12 -5
View File
@@ -1,6 +1,6 @@
{
"id": "fc5d3d01-2097-4498-b5b0-525eb5f8417c",
"prevId": "9106b694-50a6-4b6b-b33a-26d958844097",
"id": "d26e1e78-ce2d-4084-bceb-c9e058a2b610",
"prevId": "90c26bd4-552c-445a-8491-487bcf705fa6",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -262,8 +262,8 @@
"primaryKey": false,
"notNull": true
},
"credential_i_d": {
"name": "credential_i_d",
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true
@@ -381,6 +381,12 @@
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
@@ -2388,7 +2394,8 @@
"values": [
"postgresql",
"mysql",
"mongodb"
"mongodb",
"sqlite"
]
},
"public.status": {
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -243,22 +243,15 @@
{
"idx": 34,
"version": "7",
"when": 1770991368921,
"tag": "0034_vengeful_blacklash",
"when": 1771790675240,
"tag": "0034_lush_speed",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1770993283219,
"tag": "0035_windy_shockwave",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1771842940506,
"tag": "0036_left_longshot",
"when": 1771922367910,
"tag": "0035_late_young_avengers",
"breakpoints": true
}
]
+126 -123
View File
@@ -1,175 +1,178 @@
import {relations} from "drizzle-orm";
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";
import {member} from "@/db/schema/04_member";
import {invitation} from "@/db/schema/05_invitation";
import {organization} from "@/db/schema/03_organization";
import {Account as BetterAuthAccount} from "better-auth";
import {timestamps} from "@/db/schema/00_common";
import { relations } from "drizzle-orm";
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";
import { member } from "@/db/schema/04_member";
import { invitation } from "@/db/schema/05_invitation";
import { organization } from "@/db/schema/03_organization";
import { Account as BetterAuthAccount } from "better-auth";
import { timestamps } from "@/db/schema/00_common";
export const userThemeEnum = pgEnum("user_themes", ["light", "dark", "system"]);
export const user = pgTable("user", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull(),
image: text("image"),
role: text("role"),
theme: userThemeEnum().notNull().default("light"),
banned: boolean("banned"),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
lastConnectedAt: timestamp(),
lastChangedPasswordAt: timestamp(),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
...timestamps
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull(),
image: text("image"),
role: text("role"),
theme: userThemeEnum().notNull().default("light"),
banned: boolean("banned"),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
lastConnectedAt: timestamp(),
lastChangedPasswordAt: timestamp(),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
...timestamps,
});
export const session = pgTable("session", {
id: uuid("id").defaultRandom().primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: uuid("user_id")
.notNull()
.references(() => user.id, {onDelete: "cascade"}),
providerId: text("provider_id"),
impersonatedBy: text("impersonated_by"), //id or name ????
activeOrganizationId: text("active_organization_id"),
...timestamps
id: uuid("id").defaultRandom().primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: uuid("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
providerId: text("provider_id"),
impersonatedBy: text("impersonated_by"), //id or name ????
activeOrganizationId: text("active_organization_id"),
...timestamps,
});
export const account = pgTable("account", {
id: uuid("id").defaultRandom().primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: uuid("user_id")
.notNull()
.references(() => user.id, {onDelete: "cascade"}),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
...timestamps
id: uuid("id").defaultRandom().primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: uuid("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
...timestamps,
});
export const verification = pgTable("verification", {
id: uuid("id").defaultRandom().primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
...timestamps
id: uuid("id").defaultRandom().primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
...timestamps,
});
export const passkey = pgTable("passkey", {
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("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
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("credential_id").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", {
id: uuid().defaultRandom().primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: uuid("user_id")
.notNull()
.references(() => user.id, {onDelete: "cascade"}),
id: uuid().defaultRandom().primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: uuid("user_id")
.notNull()
.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(),
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 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}) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({one}) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
}));
export const projectRelations = relations(project, ({one}) => ({
organization: one(organization, {
fields: [project.organizationId],
references: [organization.id],
}),
export const projectRelations = relations(project, ({ one }) => ({
organization: one(organization, {
fields: [project.organizationId],
references: [organization.id],
}),
}));
export const passkeyRelations = relations(passkey, ({ one }) => ({
user: one(user, {
fields: [passkey.userId],
references: [user.id],
}),
user: one(user, {
fields: [passkey.userId],
references: [user.id],
}),
}));
export const userSchema = createSelectSchema(user);
export type User = z.infer<typeof userSchema>;
export const userThemeEnumSchema = createSelectSchema(userThemeEnum)
export const userThemeEnumSchema = createSelectSchema(userThemeEnum);
export type UserThemeEnum = z.infer<typeof userThemeEnumSchema>;
export const sessionSchema = createSelectSchema(session);
export type Session = z.infer<typeof sessionSchema>;
export const accountSchema = createSelectSchema(account);
export type Account = z.infer<typeof accountSchema>;
type FixedAccount = Omit<BetterAuthAccount, 'updatedAt'> & {
updatedAt: Date | null;
type FixedAccount = Omit<BetterAuthAccount, "updatedAt"> & {
updatedAt: Date | null;
};
export type UserWithAccounts = User & {
accounts: FixedAccount[];
};
accounts: FixedAccount[];
};
-1
View File
@@ -9,7 +9,6 @@ import {timestamps} from "@/db/schema/00_common";
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
import {storageChannel} from "@/db/schema/12_storage-channel";
export const database = pgTable("databases", {
id: uuid("id").primaryKey().defaultRandom(),
+1 -1
View File
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb"]);
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb", "sqlite"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+11 -9
View File
@@ -7,7 +7,6 @@ import {User, UserThemeEnum} from "@/db/schema/02_user";
export async function createUserDb(data: SignUpUser): Promise<User> {
const now = new Date();
const hashedPassword = await hashPassword(data.password);
const userId = crypto.randomUUID();
const [newUser] = await db.insert(drizzleDb.schemas.user).values({
@@ -21,14 +20,17 @@ export async function createUserDb(data: SignUpUser): Promise<User> {
theme: data.theme as UserThemeEnum,
}).returning();
await db.insert(drizzleDb.schemas.account).values({
providerId: "credential",
accountId: userId,
userId: userId,
password: hashedPassword,
createdAt: now,
updatedAt: now,
});
if (data.password) {
const hashedPassword = await hashPassword(data.password);
await db.insert(drizzleDb.schemas.account).values({
providerId: "credential",
accountId: userId,
userId: userId,
password: hashedPassword,
createdAt: now,
updatedAt: now,
});
}
return newUser
}
+24 -7
View File
@@ -52,6 +52,15 @@ export const env = createEnv({
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
AUTH_OIDC_PKCE: z.string().optional(),
AUTH_SOCIAL_ID: z.string().optional().default("social"),
AUTH_SOCIAL_TITLE: z.string().optional(),
AUTH_SOCIAL_DESC: z.string().optional(),
AUTH_SOCIAL_ICON: z.string().optional(),
AUTH_SOCIAL_CLIENT: z.string().optional(),
AUTH_SOCIAL_SECRET: z.string().optional(),
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
ALLOWED_GROUP: z.string().optional(),
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
@@ -59,6 +68,10 @@ export const env = createEnv({
AUTH_PASSKEY_ENABLED: z.string().optional().default("true"),
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
AUTH_ROLE_MAP: z.string().optional(),
AUTH_DEFAULT_ROLE: z.string().optional(),
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("false"),
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("false"),
PRIVATE_PATH: z.string().optional(),
},
@@ -82,13 +95,6 @@ export const env = createEnv({
SMTP_USER: process.env.SMTP_USER,
SMTP_SECURE: process.env.SMTP_SECURE,
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
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,
RETENTION_CRON: process.env.RETENTION_CRON,
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
@@ -103,6 +109,14 @@ export const env = createEnv({
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,
AUTH_SOCIAL_ID: process.env.AUTH_SOCIAL_ID,
AUTH_SOCIAL_TITLE: process.env.AUTH_SOCIAL_TITLE,
AUTH_SOCIAL_DESC: process.env.AUTH_SOCIAL_DESC,
AUTH_SOCIAL_ICON: process.env.AUTH_SOCIAL_ICON,
AUTH_SOCIAL_CLIENT: process.env.AUTH_SOCIAL_CLIENT,
AUTH_SOCIAL_SECRET: process.env.AUTH_SOCIAL_SECRET,
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
@@ -111,6 +125,9 @@ export const env = createEnv({
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: process.env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN,
AUTH_ALLOW_LINKING: process.env.AUTH_ALLOW_LINKING,
AUTH_ALLOW_UNLINKING: process.env.AUTH_ALLOW_UNLINKING,
PRIVATE_PATH:
process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
},
+132 -112
View File
@@ -1,141 +1,161 @@
"use server"
import {mkdir, unlink} from 'fs/promises';
import path from 'path';
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from '../types';
"use server";
import { mkdir, unlink } from "fs/promises";
import path from "path";
import {
StorageDeleteInput,
StorageGetInput,
StorageMetaData,
StorageResult,
StorageUploadInput,
} from "../types";
import fs from "node:fs";
import {generateFileUrl} from "@/features/storages/helpers";
import {Readable} from "node:stream";
import {env} from "@/env.mjs";
import { generateFileUrl } from "@/features/storages/helpers";
import { Readable } from "node:stream";
import { env } from "@/env.mjs";
const BASE_DIR = path.join(env.PRIVATE_PATH, '/uploads')
const BASE_DIR = path.join(env.PRIVATE_PATH, "/uploads");
export async function uploadLocal(
config: { baseDir?: string },
input: { data: StorageUploadInput; metadata?: StorageMetaData }
config: { baseDir?: string },
input: { data: StorageUploadInput; metadata?: StorageMetaData },
): Promise<StorageResult> {
const base = config.baseDir ? path.join(process.cwd(), config.baseDir ?? "") : BASE_DIR;
const base = config.baseDir
? path.join(process.cwd(), config.baseDir ?? "")
: BASE_DIR;
const fullPath = path.join(base, input.data.path);
const dir = path.dirname(fullPath);
const fullPath = path.join(base, input.data.path);
const dir = path.dirname(fullPath);
await mkdir(dir, { recursive: true });
await mkdir(dir, { recursive: true });
try {
const file = input.data.file;
if (Buffer.isBuffer(file)) {
await fs.promises.writeFile(fullPath, input.data.file);
} else if (file instanceof Readable) {
await new Promise<void>((resolve, reject) => {
const writable = fs.createWriteStream(fullPath);
file.pipe(writable);
writable.on("finish", resolve);
writable.on("error", reject);
});
} else {
return { success: false, provider: "local", error: "Unsupported file type. Must be Buffer or ReadableStream" };
}
if (input.data.url) {
const url = await generateFileUrl(input);
if (!url) {
return { success: false, provider: "local", response: "Unable to get URL" };
}
return { success: true, provider: "local", url };
}
return { success: true, provider: "local" };
} catch (err: any) {
try { await unlink(fullPath); } catch {}
return { success: false, provider: "local", error: err.message || "Upload failed" };
try {
const file = input.data.file;
if (Buffer.isBuffer(file)) {
await fs.promises.writeFile(fullPath, input.data.file);
} else if (file instanceof Readable) {
await new Promise<void>((resolve, reject) => {
const writable = fs.createWriteStream(fullPath);
file.pipe(writable);
writable.on("finish", resolve);
writable.on("error", reject);
});
} else {
return {
success: false,
provider: "local",
error: "Unsupported file type. Must be Buffer or ReadableStream",
};
}
if (input.data.url) {
const url = await generateFileUrl(input);
if (!url) {
return {
success: false,
provider: "local",
response: "Unable to get URL",
};
}
return { success: true, provider: "local", url };
}
return { success: true, provider: "local" };
} catch (err: any) {
try {
await unlink(fullPath);
} catch {}
return {
success: false,
provider: "local",
error: err.message || "Upload failed",
};
}
}
export async function getLocal(
config: { baseDir?: string },
input: { data: StorageGetInput; metadata: StorageMetaData }
config: { baseDir?: string },
input: { data: StorageGetInput; metadata: StorageMetaData },
): Promise<StorageResult> {
const base = config.baseDir
? path.join(process.cwd(), config.baseDir ?? "")
: BASE_DIR;
const filePath = path.join(base, input.data.path);
const base = config.baseDir ? path.join(process.cwd(), config.baseDir ?? "") : BASE_DIR;
const filePath = path.join(base, input.data.path);
if (!fs.existsSync(filePath)) {
console.error("File not found at:", filePath);
return {
success: false,
provider: "local",
error: "File not found",
};
}
if (!fs.existsSync(filePath)) {
console.error("File not found at:", filePath);
return {
success: false,
provider: "local",
error: "File not found",
};
}
let fileStream: fs.ReadStream | undefined;
let fileStream: fs.ReadStream | undefined;
try {
fileStream = fs.createReadStream(filePath);
} catch (err: any) {
console.error("Error creating read stream:", err);
return {
success: false,
provider: "local",
error: err.message,
};
}
try {
fileStream = fs.createReadStream(filePath);
} catch (err: any) {
console.error("Error creating read stream:", err);
return {
success: false,
provider: "local",
error: err.message,
};
}
if (input.data.signedUrl) {
const url = await generateFileUrl(input);
if (!url) {
return {
success: false,
provider: "local",
error: "Unable to generate signed URL",
};
}
return {
success: true,
provider: "local",
file: fileStream,
url,
};
if (input.data.signedUrl) {
const url = await generateFileUrl(input);
if (!url) {
return {
success: false,
provider: "local",
error: "Unable to generate signed URL",
};
}
return {
success: true,
provider: "local",
file: fileStream,
success: true,
provider: "local",
file: fileStream,
url,
};
}
return {
success: true,
provider: "local",
file: fileStream,
};
}
export async function deleteLocal(
config: { baseDir?: string },
input: { data: StorageDeleteInput, metadata?: StorageMetaData }
config: { baseDir?: string },
input: { data: StorageDeleteInput; metadata?: StorageMetaData },
): Promise<StorageResult> {
const base = config.baseDir
? path.join(process.cwd(), config.baseDir ?? "")
: BASE_DIR;
const fullPath = path.join(base, input.data.path);
const base = config.baseDir ? path.join(process.cwd(), config.baseDir ?? "") : BASE_DIR;
const fullPath = path.join(base, input.data.path);
await unlink(fullPath);
return {
success: true,
provider: 'local',
};
await unlink(fullPath);
return {
success: true,
provider: "local",
};
}
export async function pingLocal(
config: { baseDir?: string }
): Promise<StorageResult> {
export async function pingLocal(config: {
baseDir?: string;
}): Promise<StorageResult> {
const base = path.join(process.cwd(), config.baseDir ?? "") || BASE_DIR;
const fullPath = path.join(base, "ping.txt");
const base = path.join(process.cwd(), config.baseDir ?? "") || BASE_DIR;
const fullPath = path.join(base, "ping.txt");
await fs.promises.writeFile(fullPath, "ping");
await fs.promises.readFile(fullPath);
await fs.promises.unlink(fullPath);
return {
success: true,
provider: 'local',
response: "Local storage OK"
};
}
await fs.promises.writeFile(fullPath, "ping");
await fs.promises.readFile(fullPath);
await fs.promises.unlink(fullPath);
return {
success: true,
provider: "local",
response: "Local storage OK",
};
}
+51 -58
View File
@@ -35,10 +35,11 @@ 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 { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { passkey } from "@better-auth/passkey";
import { getOidcProviders } from "./oidc";
import { APIError } from "better-auth/api";
import { getOAuthProviders } from "./oauth";
const oidcProviders = getOidcProviders();
@@ -54,6 +55,11 @@ export const auth = betterAuth({
enabled: env.AUTH_EMAIL_PASSWORD_ENABLED === "true",
requireEmailVerification: false,
sendResetPassword: async ({ user, token }, request) => {
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
throw new APIError("FORBIDDEN", {
message: "Password reset is disabled.",
});
}
await db
.update(drizzleDb.schemas.user)
.set(
@@ -104,37 +110,33 @@ 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.type === "sso") return acc;
if (provider.id === "google") {
acc.google = {
clientId: env.AUTH_GOOGLE_ID! as string,
clientSecret: env.AUTH_GOOGLE_SECRET! as string,
};
}
if (provider.id === "github") {
acc.github = {
// clientId: provider.credentials?.clientId,
// clientSecret: provider.credentials?.clientSecret,
};
}
socialProviders: getOAuthProviders().reduce<
Record<string, { clientId: string; clientSecret: string }>
>((acc, provider) => {
const configEntry = SUPPORTED_PROVIDERS.find((p) => p.id === provider.id);
if (!configEntry?.isActive) {
return acc;
},
{},
),
}
acc[provider.id] = {
clientId: provider.client,
clientSecret: provider.secret,
...(provider.id === "apple" && provider.appleBundleIdentifier
? { appBundleIdentifier: provider.appleBundleIdentifier }
: {}),
};
return acc;
}, {}),
account: {
accountLinking: {
enabled: true,
trustedProviders: [
"google",
"github",
"credential",
...getOAuthProviders().map((p) => p.id),
...oidcProviders.map((p) => p.id),
],
allowDifferentEmails: false,
allowDifferentEmails: true,
},
},
@@ -288,6 +290,14 @@ export const auth = betterAuth({
database: {
generateId: false,
},
cookies: {
state: {
attributes: {
sameSite: "none",
secure: true,
},
},
},
},
user: {
deleteUser: {
@@ -320,6 +330,7 @@ export const auth = betterAuth({
const provider = SUPPORTED_PROVIDERS.find(
(p) => p.id === account.providerId,
);
if (provider && provider.allowLinking === false) {
throw new APIError("FORBIDDEN", {
message: "Linking is disabled for this provider.",
@@ -413,50 +424,28 @@ export const auth = betterAuth({
const url =
context?.request?.url || context?.headers?.get("referer") || "";
let providerId: string;
let providerId: string | undefined;
if (url.includes("/sso/callback")) {
const urlObj = new URL(url, "http://localhost");
providerId = urlObj.searchParams.get("providerId") || "sso";
console.log(`Found provider: ${providerId}`);
if (url) {
const urlPath = new URL(url).pathname;
const pathParts = urlPath.split("/");
const lastPathPart = pathParts[pathParts.length - 1];
if (urlPath.startsWith("/api/auth/sso/callback/")) {
providerId = lastPathPart;
} else if (urlPath.startsWith("/api/auth/callback/")) {
providerId = lastPathPart;
}
}
return {
data: {
activeOrganizationId: memberships[0].organizationId,
activeOrganizationId: memberships[0]?.organizationId,
providerId: providerId,
},
};
},
// after: async (session) => {
// const user = await db.query.user.findFirst({
// where: eq(drizzleDb.schemas.user.id, session.userId),
// });
//
// if (user && user.role != "pending") {
// const deviceInfo = getDeviceDetails(session.userAgent);
// await sendEmail({
// to: user.email,
// subject: "New login to your account",
// html: await render(
// EmailNewLogin({
// firstname: user.name!,
// os: deviceInfo.os,
// browser: deviceInfo.browser,
// ipAddress: session.ipAddress!,
// }),
// {}
// ),
// });
//
// (await auth.$context).internalAdapter.updateUser(user.id, {
// lastConnectedAt: new Date(),
// });
// }
// },
after: async (session) => {
console.log("session", session);
const user = await db.query.user.findFirst({
where: eq(drizzleDb.schemas.user.id, session.userId),
});
@@ -512,6 +501,10 @@ export const auth = betterAuth({
type: "string",
required: false,
},
providerId: {
type: "string",
required: false,
},
},
},
/* databaseHooks: {
+26 -23
View File
@@ -1,5 +1,7 @@
import { env } from "@/env.mjs";
import { getOidcProviders } from "./oidc";
import { getOAuthProviders } from "./oauth";
import * as BetterAuthSocialProviders from "better-auth/social-providers";
export interface AuthProviderConfig {
id: string;
@@ -15,6 +17,9 @@ export interface AuthProviderConfig {
}
const oidcProviders = getOidcProviders();
const oauthProviders = getOAuthProviders();
const availableSocialProviders = Object.keys(BetterAuthSocialProviders);
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
{
@@ -29,28 +34,26 @@ export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
allowLinking: true,
allowUnlinking: true,
},
{
id: "google",
isActive: !!env.AUTH_GOOGLE_ID,
name: "Google",
icon: "logos:google-icon",
title: "Google",
description: "Sign in with your Google account.",
type: "social",
allowLinking: true,
allowUnlinking: true,
},
{
id: "github",
isActive: !!env.AUTH_GITHUB_ID,
name: "GitHub",
icon: "logos:github-icon",
title: "GitHub",
description: "Sign in with your GitHub account.",
type: "social",
allowLinking: true,
allowUnlinking: true,
},
...oauthProviders.map((p) => {
const isSupported = availableSocialProviders.includes(p.id.toLowerCase());
if (!isSupported) {
console.warn(`Provider ${p.id} is not supported. Skipping...`);
}
return {
id: p.id,
isActive: isSupported,
name: p.title,
icon: p.icon,
title: p.title,
description: p.description,
isManual: false,
type: "social" as const,
allowLinking: p.allowLinking,
allowUnlinking: p.allowUnlinking,
};
}),
...oidcProviders.map((p) => ({
id: p.id,
isActive: true,
@@ -58,7 +61,7 @@ export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
icon: p.icon,
title: p.title,
description: p.description,
isManual: true,
isManual: false,
type: "sso" as const,
allowLinking: p.allowLinking,
allowUnlinking: p.allowUnlinking,
+122
View File
@@ -0,0 +1,122 @@
import { env } from "@/env.mjs";
export interface OAuthProvider {
id: string;
title: string;
description: string;
icon: string;
client: string;
secret: string;
allowedGroup?: string;
roleMap?: string;
defaultRole?: string;
allowLinking: boolean;
allowUnlinking: boolean;
appleBundleIdentifier?: string;
}
const PROVIDER_ICONS: Record<string, string> = {
google: "logos:google-icon",
github: "logos:github-icon",
gitlab: "logos:gitlab-icon",
discord: "logos:discord-icon",
facebook: "logos:facebook",
twitter: "logos:twitter",
x: "logos:x",
linkedin: "logos:linkedin-icon",
apple: "logos:apple",
microsoft: "logos:microsoft-icon",
twitch: "logos:twitch",
spotify: "logos:spotify-icon",
slack: "logos:slack-icon",
tiktok: "logos:tiktok-icon",
figma: "logos:figma",
dropbox: "logos:dropbox",
notion: "logos:notion-icon",
paypal: "logos:paypal",
reddit: "logos:reddit-icon",
salesforce: "logos:salesforce",
vercel: "logos:vercel-icon",
zoom: "logos:zoom-icon",
altassian: "logos:jira",
};
function getProviderIcon(providerId: string, envIcon?: string): string {
if (envIcon) return envIcon;
const normalizedId = providerId.toLowerCase();
if (PROVIDER_ICONS[normalizedId]) {
return PROVIDER_ICONS[normalizedId];
}
return `lucide:building`;
}
export function getOAuthProviders(): OAuthProvider[] {
const providers: OAuthProvider[] = [];
if (env.AUTH_SOCIAL_CLIENT && env.AUTH_SOCIAL_ID) {
providers.push({
id: env.AUTH_SOCIAL_ID,
title: env.AUTH_SOCIAL_TITLE || "OAuth",
description: env.AUTH_SOCIAL_DESC || "Sign in with your OAuth account.",
icon: env.AUTH_SOCIAL_ICON || "lucide:building",
client: env.AUTH_SOCIAL_CLIENT,
secret: env.AUTH_SOCIAL_SECRET || "",
allowedGroup: env.ALLOWED_GROUP,
roleMap: env.AUTH_ROLE_MAP,
defaultRole: env.AUTH_DEFAULT_ROLE,
allowLinking: env.AUTH_ALLOW_LINKING !== "false",
allowUnlinking: env.AUTH_ALLOW_UNLINKING !== "false",
appleBundleIdentifier: env.AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER,
});
}
const prefixes = new Set<string>();
Object.keys(process.env).forEach((key) => {
const match = key.match(/^AUTH_SOCIAL_(.+)_CLIENT$/);
if (match) {
prefixes.add(match[1]);
}
});
prefixes.forEach((prefix) => {
const client = process.env[`AUTH_SOCIAL_${prefix}_CLIENT`];
if (!client) return;
const providerId = prefix.toLowerCase();
const envTitle = process.env[`AUTH_SOCIAL_${prefix}_TITLE`];
const envDesc = process.env[`AUTH_SOCIAL_${prefix}_DESC`];
const envIcon = process.env[`AUTH_SOCIAL_${prefix}_ICON`];
const envSecret = process.env[`AUTH_SOCIAL_${prefix}_SECRET`];
const envAllowedGroup = process.env[`AUTH_SOCIAL_${prefix}_ALLOWED_GROUP`];
const envRoleMap = process.env[`AUTH_SOCIAL_${prefix}_ROLE_MAP`];
const envDefaultRole = process.env[`AUTH_SOCIAL_${prefix}_DEFAULT_ROLE`];
const envAppleBundleId =
process.env[`AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER`];
providers.push({
id: providerId,
title:
envTitle ||
prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase(),
description:
envDesc ||
`Sign in with ${prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase()}`,
icon: getProviderIcon(providerId, envIcon),
client: client,
secret: envSecret || "",
allowedGroup: envAllowedGroup || env.ALLOWED_GROUP,
roleMap: envRoleMap,
defaultRole: envDefaultRole,
allowLinking: env.AUTH_ALLOW_LINKING !== "false",
allowUnlinking: env.AUTH_ALLOW_UNLINKING !== "false",
appleBundleIdentifier: envAppleBundleId,
});
});
return providers;
}
+4 -4
View File
@@ -41,10 +41,10 @@ export function getOidcProviders(): OIDCProvider[] {
jwksEndpoint: env.AUTH_OIDC_JWKS_ENDPOINT,
pkce: env.AUTH_OIDC_PKCE === "true",
allowedGroup: env.ALLOWED_GROUP,
roleMap: process.env.AUTH_OIDC_ROLE_MAP,
defaultRole: process.env.AUTH_OIDC_DEFAULT_ROLE,
allowLinking: process.env.AUTH_OIDC_ALLOW_LINKING !== "false",
allowUnlinking: process.env.AUTH_OIDC_ALLOW_UNLINKING !== "false",
roleMap: env.AUTH_ROLE_MAP,
defaultRole: env.AUTH_DEFAULT_ROLE,
allowLinking: env.AUTH_ALLOW_LINKING !== "false",
allowUnlinking: env.AUTH_ALLOW_UNLINKING !== "false",
});
}
+1 -1
View File
@@ -2,7 +2,7 @@
export type SignUpUser = {
name: string
email: string
password: string
password?: string
callbackURL?: string
role?: string
theme: string