mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
add: oidc,passkey,disable email/password,sign-up
This commit is contained in:
@@ -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/>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
];
|
||||
Reference in New Issue
Block a user