Skip to content

Commit 697bc15

Browse files
committed
feat: login/profile dialog
1 parent 419f22b commit 697bc15

File tree

23 files changed

+407
-91
lines changed

23 files changed

+407
-91
lines changed

apps/api/api-types/index.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export type {
1515
PasskeyStartLoginApi,
1616
PasskeyFinalizeLoginApi,
1717
UserInfoApi,
18+
DeleteCredentialApi,
1819
} from '../src/schemas/index';

apps/api/src/plugins/swagger.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import fastifySwagger from '@fastify/swagger';
22
import fastifySwaggerUi from '@fastify/swagger-ui';
33
import fp from 'fastify-plugin';
4-
// @ts-expect-error IntelliJ may not support that
54
import packageJson from '../../package.json' assert {type: 'json'};
65

76
export default fp(async fastify => {

apps/api/src/routes/v1/passkey/deleteCredential.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox';
22
import {Type} from '@sinclair/typebox';
3+
import {FastifySchema} from 'fastify';
4+
import {GetApiTypes} from '../../../common/types/extract-api-types.js';
5+
6+
const schema = {
7+
params: Type.Object({
8+
credentialId: Type.String(),
9+
}),
10+
} satisfies FastifySchema;
11+
12+
export type DeleteCredentialApi = GetApiTypes<typeof schema>;
313

414
const route: FastifyPluginAsyncTypebox = async fastify => {
515
fastify.addHook(
616
'preValidation',
717
fastify.authorize({mustBeAuthenticated: true}),
818
);
919
fastify.delete(
10-
'/credentials',
20+
'/credentials/:credentialId',
1121
{
1222
schema: {
1323
params: Type.Object({
@@ -16,7 +26,9 @@ const route: FastifyPluginAsyncTypebox = async fastify => {
1626
},
1727
},
1828
async request => {
19-
return fastify.passkeysApi.credential(request.params.credentialId);
29+
return fastify.passkeysApi
30+
.credential(request.params.credentialId)
31+
.remove();
2032
},
2133
);
2234
};

apps/api/src/routes/v1/passkey/finalizeLogin.ts

+17-13
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,23 @@ const route: FastifyPluginAsyncTypebox = async fastify => {
5151
fastify.log.info(
5252
`Finalize passkey login for user with id ${request.body.id}`,
5353
);
54-
return fastify.passkeysApi.login.finalize({
55-
id: request.body.id,
56-
type: request.body.type,
57-
clientExtensionResults: request.body.clientExtensionResults,
58-
authenticatorAttachment: request.body.authenticatorAttachment,
59-
rawId: request.body.rawId,
60-
response: {
61-
clientDataJSON: request.body.response.clientDataJSON,
62-
signature: request.body.response.signature,
63-
userHandle: request.body.response.userHandle,
64-
authenticatorData: request.body.response.authenticatorData,
65-
},
66-
});
54+
try {
55+
return await fastify.passkeysApi.login.finalize({
56+
id: request.body.id,
57+
type: request.body.type,
58+
clientExtensionResults: request.body.clientExtensionResults,
59+
authenticatorAttachment: request.body.authenticatorAttachment,
60+
rawId: request.body.rawId,
61+
response: {
62+
clientDataJSON: request.body.response.clientDataJSON,
63+
signature: request.body.response.signature,
64+
userHandle: request.body.response.userHandle,
65+
authenticatorData: request.body.response.authenticatorData,
66+
},
67+
});
68+
} catch (e) {
69+
throw fastify.httpErrors.unauthorized(e.originalError.details);
70+
}
6771
});
6872
};
6973

apps/api/src/schemas/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export type {PasskeyStartRegistrationApi} from '../routes/v1/passkey/startRegist
1515
export type {PasskeyFinalizeRegistrationApi} from '../routes/v1/passkey/finalizeRegistration.js';
1616
export type {PasskeyStartLoginApi} from '../routes/v1/passkey/startLogin.js';
1717
export type {PasskeyFinalizeLoginApi} from '../routes/v1/passkey/finalizeLogin.js';
18+
export type {DeleteCredentialApi} from '../routes/v1/passkey/deleteCredential.js';
1819
export type {UserInfoApi} from '../routes/v1/user/info.js';

apps/api/tsconfig.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"extends": "fastify-tsconfig",
33
"compilerOptions": {
44
"outDir": "dist",
5-
"resolveJsonModule": false,
5+
"resolveJsonModule": true,
6+
"declaration": true,
67
"allowSyntheticDefaultImports": true,
78
"sourceMap": true,
89
"moduleResolution": "NodeNext",
@@ -15,4 +16,4 @@
1516
"include": [
1617
"./src/**/*.ts"
1718
]
18-
}
19+
}

apps/codeimage/src/components/Icons/CodeImageLogoV2.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const CodeImageLogoV2 = (
3232
<style>
3333
{`.cls-1 { fill: ${color} } .cls-2 { fill: url(#codeimage-gradient-blue); }`}
3434
{`[data-theme-mode=light] .cls-1 { fill: #000 } [data-theme-mode=light] .cls-3 { fill: #fff }`}
35+
{`[data-cui-theme=light] .cls-1 { fill: #000 } [data-cui-theme=light] .cls-3 { fill: #fff }`}
3536
</style>
3637
<linearGradient
3738
id="codeimage-gradient-blue"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {SvgIcon, SvgIconProps} from '@codeimage/ui';
2+
3+
export function TrashIcon(props: SvgIconProps) {
4+
return (
5+
<SvgIcon
6+
width="24"
7+
height="24"
8+
viewBox="0 0 24 24"
9+
fill="none"
10+
stroke="currentColor"
11+
stroke-width="2"
12+
stroke-linecap="round"
13+
stroke-linejoin="round"
14+
{...props}
15+
>
16+
<path d="M3 6h18" />
17+
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
18+
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
19+
<line x1="10" x2="10" y1="11" y2="17" />
20+
<line x1="14" x2="14" y1="11" y2="17" />
21+
</SvgIcon>
22+
);
23+
}

apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.css.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@ export const titleLogin = style({
99
flexDirection: 'column',
1010
gap: themeTokens.spacing['4'],
1111
alignItems: 'center',
12-
marginTop: themeTokens.spacing['8'],
12+
marginTop: themeTokens.spacing['6'],
1313
});
1414

1515
export const loginBox = style({
1616
display: 'flex',
1717
flexDirection: 'column',
1818
gap: themeTokens.spacing['2'],
1919
marginTop: themeTokens.spacing['12'],
20-
marginBottom: themeTokens.spacing['12'],
2120
});
2221

2322
export const closeIcon = style({
@@ -38,3 +37,7 @@ const backdropFilter = keyframes({
3837
globalStyle('div[data-panel-size]:has(div[id=loginDialog-content])', {
3938
animation: `${backdropFilter} 150ms normal forwards ease-in-out`,
4039
});
40+
41+
globalStyle('[data-cui-theme=dark] div[id=loginDialog-content]', {
42+
background: themeVars.accent1,
43+
});

apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.tsx

+3-18
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {ControlledDialogProps} from '@core/hooks/createControlledDialog';
1212
import {GithubLoginButton} from '@ui/GithubLoginButton/GithubLoginButton';
1313
import {CloseIcon} from '../../Icons/CloseIcon';
1414
import {CodeImageLogoV2} from '../../Icons/CodeImageLogoV2';
15+
import {KeyIcon} from '../../UserBadge/KeyIcon';
1516
import {closeIcon, loginBox, titleLogin} from './LoginDialog.css';
1617

1718
export function LoginDialog(props: ControlledDialogProps) {
@@ -37,25 +38,9 @@ export function LoginDialog(props: ControlledDialogProps) {
3738
<GithubLoginButton />
3839

3940
<Button
40-
size={'lg'}
41+
size={'md'}
4142
theme={'secondary'}
42-
leftIcon={
43-
<SvgIcon
44-
xmlns="http://www.w3.org/2000/svg"
45-
width="24"
46-
height="24"
47-
viewBox="0 0 24 24"
48-
fill="none"
49-
stroke="currentColor"
50-
stroke-width="2"
51-
stroke-linecap="round"
52-
stroke-linejoin="round"
53-
size={'1.15rem'}
54-
>
55-
<path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z" />
56-
<circle cx="16.5" cy="7.5" r=".5" />
57-
</SvgIcon>
58-
}
43+
leftIcon={<KeyIcon size={'md'} />}
5944
onClick={() => authState.providers.hanko.login()}
6045
>
6146
Login with Passkey
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {themeTokens, themeVars} from '@codeui/kit';
2+
import {style} from '@vanilla-extract/css';
3+
4+
export const profileBox = style({
5+
backgroundColor: themeVars.formAccent,
6+
padding: themeTokens.spacing['4'],
7+
borderRadius: themeTokens.radii.md,
8+
gap: themeTokens.spacing['4'],
9+
display: 'flex',
10+
alignItems: 'center',
11+
});
12+
13+
export const passkeysBox = style({
14+
marginTop: themeTokens.spacing['4'],
15+
backgroundColor: themeVars.formAccent,
16+
padding: themeTokens.spacing['4'],
17+
borderRadius: themeTokens.radii.md,
18+
});
19+
20+
export const passkeysBoxTitle = style({
21+
display: 'flex',
22+
justifyContent: 'space-between',
23+
alignItems: 'center',
24+
marginBottom: themeTokens.spacing['4'],
25+
});
26+
27+
export const passkeysList = style({
28+
display: 'flex',
29+
flexDirection: 'column',
30+
borderRadius: themeTokens.radii.sm,
31+
background: themeVars.formAccentBorder,
32+
});
33+
34+
export const passkeyItem = style({
35+
height: '42px',
36+
display: 'flex',
37+
alignItems: 'center',
38+
paddingLeft: themeTokens.spacing['3'],
39+
paddingRight: themeTokens.spacing['3'],
40+
justifyContent: 'space-between',
41+
42+
selectors: {
43+
'&:not(:last-child)': {
44+
borderBottom: `1px solid ${themeVars.separator}`,
45+
},
46+
},
47+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {AuthState} from '@codeimage/store/auth/auth';
2+
import {provideAppState} from '@codeimage/store/index';
3+
import {HStack, Loading, Text, VStack} from '@codeimage/ui';
4+
import {
5+
As,
6+
Button,
7+
Dialog,
8+
DialogPanelContent,
9+
DialogPanelFooter,
10+
IconButton,
11+
Popover,
12+
PopoverContent,
13+
PopoverTrigger,
14+
} from '@codeui/kit';
15+
import {ControlledDialogProps} from '@core/hooks/createControlledDialog';
16+
import {For, Suspense} from 'solid-js';
17+
import {createResource} from 'solid-js';
18+
import {deletePasskey, listPasskeys} from '../../../data-access/passkey';
19+
import {PencilAlt} from '../../Icons/Pencil';
20+
import {TrashIcon} from '../../Icons/TrashIcon';
21+
import {KeyIcon} from '../../UserBadge/KeyIcon';
22+
import {UserBadge} from '../../UserBadge/UserBadge';
23+
import {
24+
passkeyItem,
25+
passkeysBox,
26+
passkeysBoxTitle,
27+
passkeysList,
28+
profileBox,
29+
} from './ProfileDialog.css';
30+
31+
type ProfileDialog = ControlledDialogProps;
32+
export function ProfileDialog(props: ProfileDialog) {
33+
const authState = provideAppState(AuthState);
34+
const [passkeys, {mutate, refetch}] = createResource(() => listPasskeys());
35+
36+
const removePasskey = (id: string) => {
37+
return deletePasskey({params: {credentialId: id}}).then(() => {
38+
mutate(passkeys => (passkeys ?? []).filter(passkey => passkey.id !== id));
39+
});
40+
};
41+
42+
return (
43+
<Dialog
44+
title={'Profile'}
45+
open={props.isOpen}
46+
onOpenChange={open => props.onOpenChange(open)}
47+
size={'lg'}
48+
>
49+
<DialogPanelContent>
50+
<div class={profileBox}>
51+
<UserBadge />
52+
53+
{authState().user?.email}
54+
</div>
55+
<div class={passkeysBox}>
56+
<div class={passkeysBoxTitle}>
57+
<Text size={'lg'} weight={'semibold'}>
58+
Passkeys
59+
</Text>
60+
61+
<Button
62+
theme={'primary'}
63+
disabled={passkeys.loading}
64+
size={'sm'}
65+
leftIcon={<KeyIcon size={'sm'} />}
66+
onClick={() =>
67+
authState.providers.hanko
68+
.registerPasskey()
69+
.then(() => refetch())
70+
}
71+
>
72+
Add passkey
73+
</Button>
74+
</div>
75+
76+
<Suspense fallback={<Loading />}>
77+
<ul class={passkeysList}>
78+
<For each={passkeys()}>
79+
{passkey => (
80+
<li class={passkeyItem}>
81+
{passkey.id}
82+
<HStack spacing={'1'}>
83+
<IconButton
84+
theme={'secondary'}
85+
size={'xs'}
86+
aria-label={'Delete'}
87+
onClick={() => removePasskey(passkey.id)}
88+
>
89+
<PencilAlt size={'sm'} />
90+
</IconButton>
91+
92+
<Popover>
93+
<PopoverTrigger asChild>
94+
<As
95+
component={IconButton}
96+
theme={'secondary'}
97+
size={'xs'}
98+
aria-label={'Delete'}
99+
>
100+
<TrashIcon size={'sm'} />
101+
</As>
102+
</PopoverTrigger>
103+
<PopoverContent>
104+
<VStack spacing={'4'}>
105+
<Text size={'sm'}>
106+
Do you want to delete this passkey from your
107+
account?
108+
</Text>
109+
<div>
110+
<Button
111+
theme={'negative'}
112+
size={'xs'}
113+
aria-label={'Delete'}
114+
onClick={() => removePasskey(passkey.id)}
115+
>
116+
Confirm
117+
</Button>
118+
</div>
119+
</VStack>
120+
</PopoverContent>
121+
</Popover>
122+
</HStack>
123+
</li>
124+
)}
125+
</For>
126+
</ul>
127+
</Suspense>
128+
</div>
129+
</DialogPanelContent>
130+
<DialogPanelFooter>
131+
<Button theme={'secondary'} onClick={() => props.onOpenChange(false)}>
132+
Close
133+
</Button>
134+
</DialogPanelFooter>
135+
</Dialog>
136+
);
137+
}

0 commit comments

Comments
 (0)