1
0
Fork 0
mirror of https://codeberg.org/MarkusThielker/next-ory.git synced 2025-07-01 12:39:18 +00:00

NORY-1: add user session management

This commit is contained in:
Markus Thielker 2024-11-24 00:06:00 +01:00
parent a74e7f3ebd
commit e2a8f1d2a4
No known key found for this signature in database
9 changed files with 934 additions and 143 deletions

View file

@ -1,18 +1,21 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { SettingsFlow, UpdateSettingsFlowBody } from '@ory/client';
import { kratos } from '@/ory/sdk/kratos';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import AccountSettings from '@/components/accountSettings';
import { HandleError, kratos, LogoutLink } from '@/ory';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'sonner';
import { AxiosError } from 'axios';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Flow, HandleError, LogoutLink } from '@/ory';
import { ThemeToggle } from '@/components/themeToggle';
import { Button } from '@/components/ui/button';
import { LogOut } from 'lucide-react';
import AccountSessions from '@/components/accountSessions';
import { AxiosError } from 'axios';
import { SettingsFlow, UpdateSettingsFlowBody } from '@ory/client';
import { toast } from 'sonner';
export default function Home() {
export default function Page() {
const onLogout = LogoutLink();
const [flow, setFlow] = useState<SettingsFlow>();
@ -22,8 +25,6 @@ export default function Home() {
const returnTo = params.get('return_to') ?? undefined;
const flowId = params.get('flow') ?? undefined;
const onLogout = LogoutLink();
const getFlow = useCallback((flowId: string) => {
return kratos
.getSettingsFlow({ id: String(flowId) })
@ -41,7 +42,7 @@ export default function Home() {
.createBrowserSettingsFlow({ returnTo })
.then(({ data }) => {
setFlow(data);
router.push(`?flow=${data.id}`);
addQueryParam('flow', data.id);
})
.catch(handleError);
}, [handleError]);
@ -98,6 +99,14 @@ export default function Home() {
}, [flowId, router, returnTo, createFlow, getFlow]);
const addQueryParam = useCallback((name: string, value: string) => {
const newParams = new URLSearchParams(params.toString());
newParams.set(name, value);
router.push('?' + newParams.toString());
},
[params],
);
return (
<div className="flex flex-col min-h-screen items-center text-3xl relative space-y-4">
<div className="absolute flex flex-row w-fit items-center space-x-4 top-4 right-4">
@ -106,135 +115,23 @@ export default function Home() {
<LogOut className="h-[1.2rem] w-[1.2rem]"/>
</Button>
</div>
<div className="flex flex-col items-center space-y-4 w-full max-w-md">
<p className="mt-4 py-4 text-4xl">Settings</p>
{
flow?.ui.nodes.some(({ group }) => group === 'profile') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Password
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="profile"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'password') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Password
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="password"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'totp') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
MFA
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="totp"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'oidc') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Connect Socials
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="oidc"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'link') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Connect Socials
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="link"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'webauthn') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Connect Socials
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="webauthn"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'lookup_secret') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Recovery Codes
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="lookup_secret"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
</div>
<Tabs
defaultValue="account"
value={params.get('tab') ?? undefined}
className="w-full max-w-md"
onValueChange={(value) => addQueryParam('tab', value)}>
<TabsList className="grid w-full grid-cols-2 mt-16">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="sessions">Sessions</TabsTrigger>
</TabsList>
<TabsContent value="account" className="mb-16">
<AccountSettings flow={flow} updateFlow={updateFlow}/>
</TabsContent>
<TabsContent value="sessions" className="mb-16">
<AccountSessions/>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,79 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { kratos } from '@/ory';
import { Session } from '@ory/client';
import SessionItem from '@/components/sessionItem';
import { Separator } from '@/components/ui/separator';
export default function AccountSessions() {
const [current, setCurrent] = useState<Session>();
const [sessions, setSessions] = useState<Session[]>();
const invalidateSession = useCallback(async (id: string) => {
console.log('Disabling session with id', id);
kratos.disableMySession({ id: id })
.then(() => console.log('Disabled session with id', id))
.catch(() => console.error('Error while disabling session with id', id))
.finally(() => {
loadSessions().then((response) => {
setCurrent(response.current);
setSessions(response.sessions);
});
});
}, []);
const loadSessions = useCallback(async () => {
console.log('Refreshing sessions');
const current = await kratos.toSession();
const sessions = await kratos.listMySessions();
console.log(current.data);
console.log(sessions.data);
return {
current: current.data,
sessions: sessions.data,
};
}, []);
useEffect(() => {
loadSessions().then(response => {
setCurrent(response.current);
setSessions(response.sessions);
});
}, []);
return (
<div className="flex flex-col items-center space-y-4 w-full max-w-md">
{
current ?
<SessionItem session={current!!} showInvalidate={false} invalidateSession={invalidateSession}/>
:
<></>
}
{
sessions && sessions.length > 0 ?
<Separator/>
:
<></>
}
{
sessions?.map(item => {
return (
<SessionItem
key={item.id}
session={item}
showInvalidate={true}
invalidateSession={invalidateSession}/>
);
})
}
</div>
);
}

View file

@ -0,0 +1,145 @@
'use client';
import React from 'react';
import { SettingsFlow, UpdateSettingsFlowBody } from '@ory/client';
import { Flow } from '@/ory';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface AccountSettingsProps {
flow: SettingsFlow | undefined;
updateFlow: (body: UpdateSettingsFlowBody) => Promise<void>;
}
export default function AccountSettings({ flow, updateFlow }: AccountSettingsProps) {
return (
<div className="flex flex-col items-center space-y-4 w-full max-w-md">
{
flow?.ui.nodes.some(({ group }) => group === 'profile') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Profile
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="profile"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'password') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Password
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="password"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'totp') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
MFA
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="totp"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'oidc') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Connect Socials
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="oidc"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'link') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Connect Socials
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="link"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'webauthn') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Connect Socials
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="webauthn"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
{
flow?.ui.nodes.some(({ group }) => group === 'lookup_secret') && (
<Card className="w-full max-w-md animate-fadeIn">
<CardHeader>
<CardTitle>
Recovery Codes
</CardTitle>
</CardHeader>
<CardContent>
<Flow
onSubmit={updateFlow}
flow={flow}
only="lookup_secret"
hideGlobalMessages/>
</CardContent>
</Card>
)
}
</div>
);
}

View file

@ -0,0 +1,53 @@
'use client';
import React, { useState } from 'react';
import { Session, SessionDevice } from '@ory/client';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { UAParser } from 'ua-parser-js';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
interface SessionItemProps {
session: Session;
showInvalidate: boolean;
invalidateSession: (id: string) => void;
}
export default function SessionItem({ session, showInvalidate, invalidateSession }: SessionItemProps) {
if (!session.devices || session.devices.length < 1) {
return;
}
const [device, setDevice] = useState<SessionDevice>(session.devices[0]);
const parser = new UAParser(device.user_agent);
const result = parser.getResult();
return (
<Card className="relative w-full">
<CardHeader>
<CardTitle>
{result.os.name}
</CardTitle>
<CardDescription>
{result.browser.name}, version {result.browser.version} <br/>
Signed in since {new Date(session.authenticated_at!!).toLocaleString()}
</CardDescription>
</CardHeader>
{
showInvalidate ?
<Button
className="absolute top-4 right-4"
onClick={() => invalidateSession(session.id)}>
Invalidate
</Button>
:
<Badge
className="absolute top-4 right-4">
This session
</Badge>
}
</Card>
);
}

View file

@ -0,0 +1,37 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {
}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View file

@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };