diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..87876d3 Binary files /dev/null and b/.DS_Store differ diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4e4b960 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +/* +!/src \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..701ab02 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,88 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true + }, + extends: [ + 'eslint:recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/strict-type-checked', + 'prettier' + ], + overrides: [], + parser: "@typescript-eslint/parser", + parserOptions: { + project: './tsconfig.json' + }, + plugins: [ + "@typescript-eslint", + "import" + ], + root: true, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "ignoreRestSiblings": true, + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/consistent-type-definitions": [ + "warn", + "type" + ], + "@typescript-eslint/consistent-type-imports": [ + "error" + ], + "sort-imports": [ + "warn", + { + ignoreCase: false, + ignoreDeclarationSort: true, // don"t want to sort import lines, use eslint-plugin-import instead + ignoreMemberSort: false, + memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], + allowSeparatedGroups: true, + }, + ], + + "import/no-unresolved": "error", + "import/newline-after-import": [ + "warn", + { + "count": 1 + } + ], + 'import/order': [ + 'warn', + { + groups: [ + 'builtin', // Built-in imports (come from NodeJS native) go first + 'external', // <- External imports + 'internal', // <- Absolute imports + ['sibling', 'parent'], // <- Relative imports, the sibling and parent types they can be mingled together + 'index', // <- index imports + 'unknown', // <- unknown + ], + 'newlines-between': 'ignore', + alphabetize: { + /* sort in ascending order. Options: ["ignore", "asc", "desc"] */ + order: 'asc', + /* ignore case. Options: [true, false] */ + caseInsensitive: true, + }, + }, + ] + }, + settings: { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"] + }, + "import/resolver": { + "typescript": true + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1dde9d7..b512c09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1 @@ -.DS_Store -node_modules -/build -/.svelte-kit -/package -.env -.env.* -!.env.example -vite.config.js.timestamp-* -vite.config.ts.timestamp-* -sqlite.db -TODO.txt - -# sst -.sst -sst.config.ts -src/sst-env.d.ts \ No newline at end of file +node_modules \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5f7b9ac --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": false, + "singleQuote": true, + "arrowParens": "avoid", + "printWidth": 100, + "quoteProps": "consistent", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "ignore", + "bracketSameLine": true, + "useTabs": false +} diff --git a/LICENSE b/LICENSE index 0b9c381..aca6dbd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Passlock +Copyright (c) 2023 Passlock Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 02c9e1b..89a2d8f 100644 --- a/README.md +++ b/README.md @@ -1,264 +1,138 @@ -
Passlock logo
- - -

SvelteKit Authentication Template

+

Passkeys, Social Login & More

+
- - - + + +

+ Typescript library for next generation authentication. Passkeys, Apple login, Google one-tap and more..
- SvelteKit authentication template project featuring Passkeys, social login (Apple and Google), mailbox verification and much more. Preline and Shadcn variants. -

-

- Demo (Preline)   |   Demo (Shadcn) + Project website ยป +
+ Demo + ยท + Documentation + ยท + Tutorial

-# Features - -1. Passkey registration and authentication -2. Apple sign in -3. Google sign in / one-tap -4. Mailbox verification (via a one time code or link) -5. Dark mode with theme selection (light/dark/system) -6. [Preline][preline] and [Shadcn][shadcn-svelte] variants - -# Screen recording - -https://github.com/passlock-dev/svelte-passkeys/assets/208345/9d3fa5cf-cacb-40c3-a388-430b27a4ae76 - -# Screenshots - -![Register a passkey](./docs/preline.png) - -

Creating a new account and passkey

-
-![Shadcn/ui variant](./docs/shadcn.png) - -

Shadcn/ui variant (dark mode)

- -# Frameworks used - -1. [Passlock][passlock] - Serverless passkey platform -2. [Superforms][superforms] - Makes form handling a breeze -3. [Lucia][lucia] - Robust session management -4. [Tailwind][tailwind] - Utility-first CSS framework -5. [Preline][preline] - Tailwind UI library 1 -6. [shadcn-svelte][shadcn-svelte] - Tailwind components for Svelte 2 -7. [Melt UI][meltui] - Headless component library for Svelte - -[1] Uses native Svelte in place of Preline JavaScript -[2] See the [shadcn branch](#shadcnui-variant) - -

(back to top)

- -# About - -The future of web authenticaton lies in [Passkeys][google-passkeys]. Learn how to add Passkey authentication to your SvelteKit app, perform facial or fingerprint recognition and more. You'll also learn how to use some of SvelteKit's hottest libraries and implement Google's latest social sign in feature. - -# Demos - -I've deployed 2 live versions of this project: - -- [Master demo](https://d1rl0ue18b0151.cloudfront.net) - A version of the master branch (uses Preline + Melt UI) - -- [Shadcn demo](https://dbr4qrmypnl85.cloudfront.net) - A version of the shadcn branch (uses shadcn-svelte) - -# Getting started - -## Prerequisites - -This example project uses the cloud based [Passlock][passlock] framework for passkey registration and authentication. **Passlock is free for personal and commercial use**. Create an account at [passlock.dev][passlock-signup] - -## Clone this repo - -`git clone git@github.com:passlock-dev/svelte-passkeys.git` - -## Install the dependencies - -``` -cd svelte-passkeys -npm install -``` - -## Set the environment variables - -You'll need to set four variables: - -1. PUBLIC_PASSLOCK_TENANCY_ID -2. PUBLIC_PASSLOCK_CLIENT_ID -3. PUBLIC_APPLE_CLIENT_ID 1 -4. PUBLIC_APPLE_REDIRECT_URL 1 -5. PUBLIC_GOOGLE_CLIENT_ID 1 -6. PASSLOCK_API_KEY +> [!IMPORTANT] +> **Looking for the SvelteKit templates?** You'll find the SvelteKit app templates in [apps/sveltekit](./apps/sveltekit/) -[1] Optional - If not using Apple/Google set to an empty string +## Features -### Where to find these variables +Passkeys and the WebAuthn API are quite complex. I've taken an opinionated approach to simplify things for you. Following the 80/20 principle, I've tried to focus on the features most valuable to developers and users. -Your Passlock Tenancy ID, Client ID and Api Key (token) can be found in your [Passlock console][passlock-console] under [settings][passlock-settings] and [API Keys][passlock-apikeys]. Please see the section [Sign in with google](#sign-in-with-google) if using Google sign in. +1. **๐Ÿ” Primary or secondary authentication** - 2FA or a complete replacement for passwords -Create a `.env.local` file containing the relevant credentials. +2. **๐Ÿš€ Social login** - Supporting Apple & Google. GitHub coming soon.. -> [!TIP] -> Alternatively you can download a ready made .env file from your passlock console [settings][passlock-settings]: -> -> `Tenancy information -> Vite .env -> Download` +3. **โ˜๐Ÿป Biometrics** - Frictionless facial or fingerprint recognition for your webapps -

(back to top)

+4. **๐Ÿ–ฅ๏ธ Management console** - Suspend users, disable or revoke passkeys and more.. -# Usage +5. **๐Ÿ•ต๏ธ Audit trail** - View a full audit trail for each user -Start the dev server +6. **๐Ÿ–ฅ๏ธ Dev console** - Something not working? check the web console for details -`npm run dev` +7. **๐Ÿ‘Œ Headless components** - You have 100% control over the UI -**Note:** by default this app runs on port 5174 when in dev mode (see [vite.config.ts](vite.config.ts)) +## Screen recording -## Register a passkey +https://github.com/user-attachments/assets/f1c21242-74cb-4739-8eff-fddb19cb3256 -Navigate to the [home page](http://localhost:5174/) page and complete the form. Assuming your browser supports passkeys (most do), you should be prompted to create a passkey. +## Screenshots -## Authenticate +![SvelteKit template using this library](./README_assets/preline.dark.png) +

Demo app using this library for passkey and social login

-Logout then navigate to the [login](http://localhost:5174/login) page. You should be prompted to authenticate using your newly created passkey. +![Passlock user profile](./README_assets/console.png) +

Viewing a user's authentication activity on their profile page

-
+## Usage > [!TIP] -> Prompting for an email address during authentication is optional but **highly recommended**. -> -> Imagine the user hasn't created a passkey, or they signed up using Google. When they try to sign in using a passkey you might expect that they would receive an error telling them that no passkey can be found, but unfortunately that's not how browsers behave. Instead the browser/device will prompt them to use a passkey on another different device. In my experience this confuses 90% of users. -> -> By asking for an email address we can check if they have a passkey registered in the backed or they have a linked Google account. This allows us to display a helpful message telling them to either sign up or login using their Google credentials. - -

(back to top)

- -# Sign in with Google - -This app also allows users to register/sign in using a Google account. It uses the latest [sign in with google][google-signin] code, avoiding redirects. - -## Adding Google sign in - -1. Obtain your [Google API Client ID][google-client-id] -2. Update your `.env` or `.env.local` to include a `PUBLIC_GOOGLE_CLIENT_ID` variable. -3. Record your Google Client ID in your [Passlock settings][passlock-settings]: Social Login -> Google Client ID - -> [!IMPORTANT] -> Don't forget the last step! - -## Testing Google sign in - -If all went well you should be able to register an account and then sign in using your Google credentials. - -**IMPORTANT!** If you previously used the same email address with another authenticator (i.e. passkey or apple), you'll need to first delete the user in your Passlock console. We don't yet support account linking in this template but it's being developed now. +> **SvelteKit users** - Whilst this library is framework agnostic, SvelteKit users may want to check out the [@passlock/sveltekit](./packages/sveltekit/) wrapper This offers several enhancements, including UI components, form action helpers and Superforms support. -

(back to top)

+Use this library to generate a secure token, representing passkey registration or authentication. Send the token to your backend for verification (see below) -# Sign in with Apple +### Register a passkey -Similar to Google, users can sign in using an Apple account, also without redirects, however that there are a few more steps and gotchas to be aware of... +```typescript +import { Passlock, PasslockError } from '@passlock/client' -1. You need a (paid) Apple developer account -2. You can't use _Sign in with Apple_ without an App ID, however **you don't need an app**, just a registered App ID. -3. You can't test using localhost, you'll need to tunnel a public, HTTPS url to your local server using something like ngrok. -4. We still need to pass a redirect URL to Apple during the authentication call, even though we tell them to use a popup ๐Ÿคฏ. In practice this means registering `https://mysite.com` with Apple and using it for `PUBLIC_APPLE_REDIRECT_URL`. Everything will still work even on `https://mysite.com/login`. -5. Apple only returns the user data (first & last name) during the first call. In normal use this isn't an issue, but if during testing you delete your account and register again, you will also need to break the link in your apple account. Go to https://appleid.apple.com -> Sign in with Apple -> Passlock Demo -> Stop using Sign in with Apple. +// you can find these details in the settings area of your Passlock console +const tenancyId = '...' +const clientId = '...' -## Adding Apple sign in +const passlock = new Passlock({ tenancyId, clientId }) -1. Create an Apple App ID with "Sign in with Apple" enabled -2. Create an Apple Service ID with "Sign in with Apple" enabled -3. Register the relevant website domains and redirect URLs with the service account -4. Update your `.env` or `.env.local` to include the `PUBLIC_APPLE_CLIENT_ID` and `PUBLIC_APPLE_REDIRECT_URL` variables. -5. Record your Apple Client ID in your [Passlock settings][passlock-settings]: Social Login -> Apple Client ID +// to register a new passkey, call registerPasskey(). We're using placeholders for +// the user data. You should grab this from an HTML form, React store, Redux etc. +const [email, givenName, familyName] = ["jdoe@gmail.com", "John", "Doe"] -

(back to top)

+// Passlock doesn't throw but instead returns a union: result | error +const result = await passlock.registerPasskey({ email, givenName, familyName }) -# Mailbox verification +// ensure we're error free +if (!PasslockError.isError(result)) { + // send the token to your backend (json/fetch or hidden form field etc) + console.log('Token: %s', result.token) +} +``` -This starter project also supports mailbox verification emails (via Passlock): +### Authenticate using a passkey -![Verifying mailbox ownership](https://github.com/passlock-dev/svelte-passkeys/assets/208345/2f7c06d6-c2a9-40f2-a8db-0a44fa378281) +```typescript +import { Passlock, PasslockError } from '@passlock/client' -You can choose to verify an email address during passkey registration. Take a look at [src/routes/(other)/+page.svelte](): +const tenancyId = '...' +const clientId = '...' -```typescript -// Email a verification link -const verifyEmailLink: VerifyEmail = { - method: 'link', - redirectUrl: String(new URL('/verify-email', $page.url)) -} +const passlock = new Passlock({ tenancyId, clientId }) +const result = await passlock.authenticatePasskey() -// Email a verification code -const verifyEmailCode: VerifyEmail = { - method: 'code' +if (!PasslockError.isError(result)) { + // send the token to your backend for verification + console.log('Token: %s', result.token) } - -// If you want to verify the user's email during registration -// choose one of the options above and take a look at /verify/email/+page.svelte -let verifyEmail: VerifyEmail | undefined = verifyEmailCode ``` -## Customizing the verification emails - -See the emails section of your [Passlock console][passlock-settings] +### Backend verification -

(back to top)

+Verify the token and obtain the passkey registration or authentication details. You can make a simple GET request to `https://api.passlock.dev/{tenancyId}/token/{token}` or use the [@passlock/node][node] library: -# Shadcn/ui variant - -![Shadcn/ui variant](./docs/shadcn.png) - -

Shadcn/ui variant

- -The default (master) branch uses [Preline][preline], however a [shadcn-svelte] variant is also available: +```typescript +import { Passlock } from '@passlock/node' -```bash -git checkout -b shadcn origin/shadcn -``` +// API Keys can be found in your passlock console +const passlock = new Passlock({ tenancyId, apiKey }) -**IMPORTANT**: When switching between branches please re-install the NPM dependencies: +// token comes from your frontend +const principal = await passlock.fetchPrincipal({ token }) -```bash -rm -r node_modules pnpm-lock.yaml -pnpm install +// get the user id +console.log(principal.user.id) ``` -

(back to top)

- -# Documentation - -Please see the [developer docs](./docs/intro.md) - -# Questions? Problems +## More information -Please file an [issue][issues] and I'll respond ASAP. +Please see the [tutorial][tutorial] and [documentation][docs] -[passlock]: https://passlock.dev -[lucia]: https://lucia-auth.com -[tailwind]: https://tailwindcss.com -[preline]: https://preline.co -[meltui]: https://melt-ui.com -[shadcn-svelte]: https://www.shadcn-svelte.com -[passlock-signup]: https://console.passlock.dev/register -[passlock-console]: https://console.passlock.dev -[passlock-settings]: https://console.passlock.dev/settings -[passlock-apikeys]: https://console.passlock.dev/apikeys -[google-signin]: https://developers.google.com/identity/gsi/web/guides/overview -[google-client-id]: https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid#get_your_google_api_client_id -[issues]: https://github.com/passlock-dev/svelte-passkeys/issues -[superforms]: https://superforms.rocks -[apple-verification-codes]: https://www.cultofmac.com/819421/ios-17-autofill-verification-codes-safari-mail-app/ -[google-passkeys]: https://safety.google/authentication/passkey/ +[contact]: https://passlock.dev/contact +[tutorial]: https://docs.passlock.dev/docs/tutorial/introduction +[docs]: https://docs.passlock.dev +[node]: https://www.npmjs.com/package/@passlock/node +[melt]: https://melt-ui.com +[shadcn]: https://www.shadcn-svelte.com diff --git a/README_assets/console.png b/README_assets/console.png new file mode 100644 index 0000000..e473dde Binary files /dev/null and b/README_assets/console.png differ diff --git a/README_assets/passlock-demo.mp4 b/README_assets/passlock-demo.mp4 new file mode 100644 index 0000000..cd1da07 Binary files /dev/null and b/README_assets/passlock-demo.mp4 differ diff --git a/README_assets/preline.dark.png b/README_assets/preline.dark.png new file mode 100644 index 0000000..f7b1370 Binary files /dev/null and b/README_assets/preline.dark.png differ diff --git a/docs/preline.png b/README_assets/preline.png similarity index 100% rename from docs/preline.png rename to README_assets/preline.png diff --git a/README_assets/repo-banner.dark.svg b/README_assets/repo-banner.dark.svg new file mode 100644 index 0000000..0851ee4 --- /dev/null +++ b/README_assets/repo-banner.dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/README_assets/repo-banner.svg b/README_assets/repo-banner.svg new file mode 100644 index 0000000..73f534f --- /dev/null +++ b/README_assets/repo-banner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/shadcn.png b/README_assets/shadcn.png similarity index 100% rename from docs/shadcn.png rename to README_assets/shadcn.png diff --git a/apps/.DS_Store b/apps/.DS_Store new file mode 100644 index 0000000..22a5e5a Binary files /dev/null and b/apps/.DS_Store differ diff --git a/apps/sveltekit/.gitignore b/apps/sveltekit/.gitignore new file mode 100644 index 0000000..7309a2d --- /dev/null +++ b/apps/sveltekit/.gitignore @@ -0,0 +1,2 @@ +preline-sst +shadcn-sst \ No newline at end of file diff --git a/apps/sveltekit/README.md b/apps/sveltekit/README.md new file mode 100644 index 0000000..60498f6 --- /dev/null +++ b/apps/sveltekit/README.md @@ -0,0 +1,267 @@ + +
+ + Passlock logo + +
+ + + +

SvelteKit Authentication Template

+ +
+ + + + +

+
+ SvelteKit authentication template featuring Passkeys, social login (Apple and Google), mailbox verification and much more.
Preline and Shadcn variants available. +

+

+ Demo (Preline)   |   Demo (Shadcn) +

+
+ +# Features + +1. Passkey registration and authentication +2. Apple sign in +3. Google sign in / one-tap +4. Mailbox verification (via a one time code or link) +5. Dark mode with theme selection (light/dark/system) +6. [Preline][preline] and [Shadcn][shadcn-svelte] variants + +# Screen recording + +https://github.com/user-attachments/assets/c1da1bea-a1c5-4930-8f57-d12728106630 + +# Screenshots + +![Register a passkey](./README_assets/preline.png) + +

Creating a new account and passkey

+ +
+ +![Shadcn/ui variant](./README_assets/shadcn.png) + +

Shadcn/ui variant (dark mode)

+ +# Frameworks used + +1. [Passlock][passlock] - Serverless passkey platform +2. [Superforms][superforms] - Makes form handling a breeze +3. [Lucia][lucia] - Robust session management +4. [Tailwind][tailwind] - Utility-first CSS framework +5. [Preline][preline] - Tailwind UI library 1 +6. [shadcn-svelte][shadcn-svelte] - Tailwind components for Svelte 2 +7. [Melt UI][meltui] - Headless component library for Svelte + +[1] Uses native Svelte in place of Preline JavaScript +[2] See the [shadcn branch](#shadcnui-variant) + +

(back to top)

+ +# About + +The future of web authenticaton lies in [Passkeys][google-passkeys]. Learn how to add Passkey authentication to your SvelteKit app, perform facial or fingerprint recognition and more. You'll also learn how to use some of SvelteKit's hottest libraries and implement Google's latest social sign in feature. + +# Demos + +I've deployed 2 live versions of this project: + +- [Master demo](https://d1rl0ue18b0151.cloudfront.net) - A version of the master branch (uses Preline + Melt UI) + +- [Shadcn demo](https://dbr4qrmypnl85.cloudfront.net) - A version of the shadcn branch (uses shadcn-svelte) + +# Getting started + +## Prerequisites + +This example project uses the cloud based [Passlock][passlock] framework for passkey registration and authentication. **Passlock is free for personal and commercial use**. Create an account at [passlock.dev][passlock-signup] + +## Clone this repo + +`git clone git@github.com:passlock-dev/passkeys.git` + +## Navigate to the template + +`cd apps/sveltekit/preline` or `cd apps/sveltekit/shadcn` + +## Install the dependencies + +``` +npm install +``` + +## Set the environment variables + +You'll need to set four variables: + +1. PUBLIC_PASSLOCK_TENANCY_ID +2. PUBLIC_PASSLOCK_CLIENT_ID +3. PUBLIC_APPLE_CLIENT_ID 1 +4. PUBLIC_APPLE_REDIRECT_URL 1 +5. PUBLIC_GOOGLE_CLIENT_ID 1 +6. PASSLOCK_API_KEY + +[1] Optional - If not using Apple/Google set to an empty string + +### Where to find these variables + +Your Passlock Tenancy ID, Client ID and Api Key (token) can be found in your [Passlock console][passlock-console] under [settings][passlock-settings] and [API Keys][passlock-apikeys]. Please see the section [Sign in with google](#sign-in-with-google) if using Google sign in. + +Create a `.env.local` file containing the relevant credentials. + +> [!TIP] +> Alternatively you can download a ready made .env file from your passlock console [settings][passlock-settings]: +> +> `Tenancy information -> Vite .env -> Download` + +

(back to top)

+ +# Usage + +Start the dev server + +`npm run dev` + +**Note:** by default this app runs on port 5174 when in dev mode (see [vite.config.ts](vite.config.ts)) + +## Register a passkey + +Navigate to the [home page](http://localhost:5174/) page and complete the form. Assuming your browser supports passkeys (most do), you should be prompted to create a passkey. + +## Authenticate + +Logout then navigate to the [login](http://localhost:5174/login) page. You should be prompted to authenticate using your newly created passkey. + +
+ +> [!TIP] +> Prompting for an email address during authentication is optional but **highly recommended**. +> +> Imagine the user hasn't created a passkey, or they signed up using Google. When they try to sign in using a passkey you might expect that they would receive an error telling them that no passkey can be found, but unfortunately that's not how browsers behave. Instead the browser/device will prompt them to use a passkey on another different device. In my experience this confuses 90% of users. +> +> By asking for an email address we can check if they have a passkey registered in the backed or they have a linked Google account. This allows us to display a helpful message telling them to either sign up or login using their Google credentials. + +

(back to top)

+ +# Sign in with Google + +This app also allows users to register/sign in using a Google account. It uses the latest [sign in with google][google-signin] code, avoiding redirects. + +## Adding Google sign in + +1. Obtain your [Google API Client ID][google-client-id] +2. Update your `.env` or `.env.local` to include a `PUBLIC_GOOGLE_CLIENT_ID` variable. +3. Record your Google Client ID in your [Passlock settings][passlock-settings]: Social Login -> Google Client ID + +> [!IMPORTANT] +> Don't forget the last step! + +## Testing Google sign in + +If all went well you should be able to register an account and then sign in using your Google credentials. + +**IMPORTANT!** If you previously used the same email address with another authenticator (i.e. passkey or apple), you'll need to first delete the user in your Passlock console. We don't yet support account linking in this template but it's being developed now. + +

(back to top)

+ +# Sign in with Apple + +Similar to Google, users can sign in using an Apple account, also without redirects, however that there are a few more steps and gotchas to be aware of... + +1. You need a (paid) Apple developer account +2. You can't use _Sign in with Apple_ without an App ID, however **you don't need an app**, just a registered App ID. +3. You can't test using localhost, you'll need to tunnel a public, HTTPS url to your local server using something like ngrok. +4. We still need to pass a redirect URL to Apple during the authentication call, even though we tell them to use a popup ๐Ÿคฏ. In practice this means registering `https://mysite.com` with Apple and using it for `PUBLIC_APPLE_REDIRECT_URL`. Everything will still work even on `https://mysite.com/login`. +5. Apple only returns the user data (first & last name) during the first call. In normal use this isn't an issue, but if during testing you delete your account and register again, you will also need to break the link in your apple account. Go to https://appleid.apple.com -> Sign in with Apple -> Passlock Demo -> Stop using Sign in with Apple. + +## Adding Apple sign in + +1. Create an Apple App ID with "Sign in with Apple" enabled +2. Create an Apple Service ID with "Sign in with Apple" enabled +3. Register the relevant website domains and redirect URLs with the service account +4. Update your `.env` or `.env.local` to include the `PUBLIC_APPLE_CLIENT_ID` and `PUBLIC_APPLE_REDIRECT_URL` variables. +5. Record your Apple Client ID in your [Passlock settings][passlock-settings]: Social Login -> Apple Client ID + +

(back to top)

+ +# Mailbox verification + +This starter project also supports mailbox verification emails (via Passlock): + +![Verifying mailbox ownership](https://github.com/passlock-dev/svelte-passkeys/assets/208345/2f7c06d6-c2a9-40f2-a8db-0a44fa378281) + +You can choose to verify an email address during passkey registration. Take a look at [src/routes/(other)/+page.svelte](): + +```typescript +// Email a verification link +const verifyEmailLink: VerifyEmail = { + method: 'link', + redirectUrl: String(new URL('/verify-email', $page.url)) +} + +// Email a verification code +const verifyEmailCode: VerifyEmail = { + method: 'code' +} + +// If you want to verify the user's email during registration +// choose one of the options above and take a look at /verify/email/+page.svelte +let verifyEmail: VerifyEmail | undefined = verifyEmailCode +``` + +## Customizing the verification emails + +See the emails section of your [Passlock console][passlock-settings] + +

(back to top)

+ +# Shadcn/ui variant + +![Shadcn/ui variant](./README_assets/shadcn.png) + +

Shadcn/ui variant

+ +The default (master) branch uses [Preline][preline], however a [shadcn-svelte] variant is also available: + +```bash +git checkout -b shadcn origin/shadcn +``` + +**IMPORTANT**: When switching between branches please re-install the NPM dependencies: + +```bash +rm -r node_modules pnpm-lock.yaml +pnpm install +``` + +

(back to top)

+ +# Documentation + +Please see the [developer docs](./preline/docs/intro.md) + +# Questions? Problems + +Please file an [issue][issues] and I'll respond ASAP. + +[passlock]: https://passlock.dev +[lucia]: https://lucia-auth.com +[tailwind]: https://tailwindcss.com +[preline]: https://preline.co +[meltui]: https://melt-ui.com +[shadcn-svelte]: https://www.shadcn-svelte.com +[passlock-signup]: https://console.passlock.dev/register +[passlock-console]: https://console.passlock.dev +[passlock-settings]: https://console.passlock.dev/settings +[passlock-apikeys]: https://console.passlock.dev/apikeys +[google-signin]: https://developers.google.com/identity/gsi/web/guides/overview +[google-client-id]: https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid#get_your_google_api_client_id +[issues]: https://github.com/passlock-dev/svelte-passkeys/issues +[superforms]: https://superforms.rocks +[apple-verification-codes]: https://www.cultofmac.com/819421/ios-17-autofill-verification-codes-safari-mail-app/ +[google-passkeys]: https://safety.google/authentication/passkey/ diff --git a/apps/sveltekit/README_assets/preline.png b/apps/sveltekit/README_assets/preline.png new file mode 100644 index 0000000..cee32d8 Binary files /dev/null and b/apps/sveltekit/README_assets/preline.png differ diff --git a/apps/sveltekit/README_assets/repo-banner.dark.svg b/apps/sveltekit/README_assets/repo-banner.dark.svg new file mode 100644 index 0000000..85ee444 --- /dev/null +++ b/apps/sveltekit/README_assets/repo-banner.dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/sveltekit/README_assets/repo-banner.svg b/apps/sveltekit/README_assets/repo-banner.svg new file mode 100644 index 0000000..ab2bdeb --- /dev/null +++ b/apps/sveltekit/README_assets/repo-banner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/sveltekit/README_assets/shadcn.png b/apps/sveltekit/README_assets/shadcn.png new file mode 100644 index 0000000..fbc657d Binary files /dev/null and b/apps/sveltekit/README_assets/shadcn.png differ diff --git a/.env.example b/apps/sveltekit/preline/.env.example similarity index 100% rename from .env.example rename to apps/sveltekit/preline/.env.example diff --git a/apps/sveltekit/preline/.gitignore b/apps/sveltekit/preline/.gitignore new file mode 100644 index 0000000..1dde9d7 --- /dev/null +++ b/apps/sveltekit/preline/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +sqlite.db +TODO.txt + +# sst +.sst +sst.config.ts +src/sst-env.d.ts \ No newline at end of file diff --git a/.npmrc b/apps/sveltekit/preline/.npmrc similarity index 100% rename from .npmrc rename to apps/sveltekit/preline/.npmrc diff --git a/apps/sveltekit/preline/.prettierignore b/apps/sveltekit/preline/.prettierignore new file mode 100644 index 0000000..cc41cea --- /dev/null +++ b/apps/sveltekit/preline/.prettierignore @@ -0,0 +1,4 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/.prettierrc b/apps/sveltekit/preline/.prettierrc similarity index 100% rename from .prettierrc rename to apps/sveltekit/preline/.prettierrc diff --git a/apps/sveltekit/preline/LICENSE b/apps/sveltekit/preline/LICENSE new file mode 100644 index 0000000..0b9c381 --- /dev/null +++ b/apps/sveltekit/preline/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Passlock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/intro.md b/apps/sveltekit/preline/docs/intro.md similarity index 95% rename from docs/intro.md rename to apps/sveltekit/preline/docs/intro.md index 22f55f5..7c286e6 100644 --- a/docs/intro.md +++ b/apps/sveltekit/preline/docs/intro.md @@ -35,12 +35,6 @@ However, Melt UI and Preline CSS classes are an awesome combination. We use **Pr > [!NOTE] > Unlike [shadcn-svelte][shadcn-svelte], I haven't ported the entire Preline framework across to Svelte. I've simply used Melt UI to build the components required by this app. However as you will see, it's really easy to build a component using Melt (or [bits-ui][bitsui]) -## shadcn-svelte - -Shadcn/ui is a collection of tailwind components. [Shadcn-svelte][shadcn-svelte] is a excellent Svelte port, built on top of [bits-ui][bitsui] (which is itself built on Melt UI). - -So you have a choice in styling between Shadcn/ui or Preline. Ultimately the behavior is handled by Melt UI. - # The code The code is quite well commented so please check it out. I'll give a quite overview of the operations, with pointers to the relevant source code. diff --git a/apps/sveltekit/preline/package.json b/apps/sveltekit/preline/package.json new file mode 100644 index 0000000..0e8bd3b --- /dev/null +++ b/apps/sveltekit/preline/package.json @@ -0,0 +1,54 @@ +{ + "name": "sveltekit-template", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev --host", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:errors": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold error", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "typecheck": "pnpm run check:errors", + "format": "prettier --write .", + "ncu": "ncu --peer -x @passlock/sveltekit -x better-sqlite3 -x postcss-load-config", + "ncu:save": "ncu --peer -x @passlock/sveltekit -x better-sqlite3 -x postcss-load-config -u", + "pnpm:upgrade": "corepack use pnpm@latest" + }, + "devDependencies": { + "@lucia-auth/adapter-sqlite": "^3.0.2", + "@melt-ui/pp": "^0.3.2", + "@melt-ui/svelte": "^0.83.0", + "@passlock/sveltekit": "0.9.20", + "@sveltejs/adapter-auto": "^3.2.2", + "@sveltejs/kit": "^2.5.18", + "@sveltejs/vite-plugin-svelte": "^3.1.1", + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.7", + "@types/apple-signin-api": "^1.5.3", + "@types/better-sqlite3": "^7.6.11", + "@types/google-one-tap": "^1.2.6", + "autoprefixer": "^10.4.19", + "better-sqlite3": "~9.6.0", + "dedent": "^1.5.3", + "js-sha256": "^0.11.0", + "lucia": "^3.2.0", + "lucide-svelte": "^0.399.0", + "mode-watcher": "^0.3.1", + "node-check-updates": "^0.1.9", + "postcss": "^8.4.39", + "postcss-load-config": "^5.1.0", + "prettier": "^3.3.2", + "prettier-plugin-svelte": "^3.2.5", + "svelte": "^4.2.18", + "svelte-check": "^3.8.4", + "sveltekit-superforms": "^2.16.0", + "tailwindcss": "^3.4.4", + "tslib": "^2.6.3", + "typescript": "^5.5.3", + "valibot": "^0.36.0", + "vite": "^5.3.3" + }, + "type": "module", + "packageManager": "pnpm@9.5.0+sha256.dbdf5961c32909fb030595a9daa1dae720162e658609a8f92f2fa99835510ca5" +} diff --git a/postcss.config.cjs b/apps/sveltekit/preline/postcss.config.cjs similarity index 100% rename from postcss.config.cjs rename to apps/sveltekit/preline/postcss.config.cjs diff --git a/src/app.d.ts b/apps/sveltekit/preline/src/app.d.ts similarity index 100% rename from src/app.d.ts rename to apps/sveltekit/preline/src/app.d.ts diff --git a/src/app.html b/apps/sveltekit/preline/src/app.html similarity index 100% rename from src/app.html rename to apps/sveltekit/preline/src/app.html diff --git a/src/app.pcss b/apps/sveltekit/preline/src/app.pcss similarity index 100% rename from src/app.pcss rename to apps/sveltekit/preline/src/app.pcss diff --git a/src/hooks.server.ts b/apps/sveltekit/preline/src/hooks.server.ts similarity index 100% rename from src/hooks.server.ts rename to apps/sveltekit/preline/src/hooks.server.ts diff --git a/src/lib/components/icons/Apple.svelte b/apps/sveltekit/preline/src/lib/components/icons/Apple.svelte similarity index 100% rename from src/lib/components/icons/Apple.svelte rename to apps/sveltekit/preline/src/lib/components/icons/Apple.svelte diff --git a/src/lib/components/icons/FieldError.svelte b/apps/sveltekit/preline/src/lib/components/icons/FieldError.svelte similarity index 100% rename from src/lib/components/icons/FieldError.svelte rename to apps/sveltekit/preline/src/lib/components/icons/FieldError.svelte diff --git a/src/lib/components/icons/Google.svelte b/apps/sveltekit/preline/src/lib/components/icons/Google.svelte similarity index 100% rename from src/lib/components/icons/Google.svelte rename to apps/sveltekit/preline/src/lib/components/icons/Google.svelte diff --git a/src/lib/components/icons/Passkey.svelte b/apps/sveltekit/preline/src/lib/components/icons/Passkey.svelte similarity index 100% rename from src/lib/components/icons/Passkey.svelte rename to apps/sveltekit/preline/src/lib/components/icons/Passkey.svelte diff --git a/src/lib/components/icons/index.ts b/apps/sveltekit/preline/src/lib/components/icons/index.ts similarity index 100% rename from src/lib/components/icons/index.ts rename to apps/sveltekit/preline/src/lib/components/icons/index.ts diff --git a/src/lib/components/layout/Banner.svelte b/apps/sveltekit/preline/src/lib/components/layout/Banner.svelte similarity index 100% rename from src/lib/components/layout/Banner.svelte rename to apps/sveltekit/preline/src/lib/components/layout/Banner.svelte diff --git a/src/lib/components/layout/Footer.svelte b/apps/sveltekit/preline/src/lib/components/layout/Footer.svelte similarity index 100% rename from src/lib/components/layout/Footer.svelte rename to apps/sveltekit/preline/src/lib/components/layout/Footer.svelte diff --git a/src/lib/components/layout/Header.svelte b/apps/sveltekit/preline/src/lib/components/layout/Header.svelte similarity index 100% rename from src/lib/components/layout/Header.svelte rename to apps/sveltekit/preline/src/lib/components/layout/Header.svelte diff --git a/src/lib/components/layout/Link.svelte b/apps/sveltekit/preline/src/lib/components/layout/Link.svelte similarity index 100% rename from src/lib/components/layout/Link.svelte rename to apps/sveltekit/preline/src/lib/components/layout/Link.svelte diff --git a/src/lib/components/layout/MenuBar.svelte b/apps/sveltekit/preline/src/lib/components/layout/MenuBar.svelte similarity index 100% rename from src/lib/components/layout/MenuBar.svelte rename to apps/sveltekit/preline/src/lib/components/layout/MenuBar.svelte diff --git a/src/lib/components/layout/RegisterButton.svelte b/apps/sveltekit/preline/src/lib/components/layout/RegisterButton.svelte similarity index 100% rename from src/lib/components/layout/RegisterButton.svelte rename to apps/sveltekit/preline/src/lib/components/layout/RegisterButton.svelte diff --git a/src/lib/components/layout/ThemeSelector.svelte b/apps/sveltekit/preline/src/lib/components/layout/ThemeSelector.svelte similarity index 100% rename from src/lib/components/layout/ThemeSelector.svelte rename to apps/sveltekit/preline/src/lib/components/layout/ThemeSelector.svelte diff --git a/src/lib/components/layout/language-selector/DE.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/DE.svelte similarity index 100% rename from src/lib/components/layout/language-selector/DE.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/DE.svelte diff --git a/src/lib/components/layout/language-selector/DK.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/DK.svelte similarity index 100% rename from src/lib/components/layout/language-selector/DK.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/DK.svelte diff --git a/src/lib/components/layout/language-selector/EN.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/EN.svelte similarity index 100% rename from src/lib/components/layout/language-selector/EN.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/EN.svelte diff --git a/src/lib/components/layout/language-selector/IT.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/IT.svelte similarity index 100% rename from src/lib/components/layout/language-selector/IT.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/IT.svelte diff --git a/src/lib/components/layout/language-selector/JP.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/JP.svelte similarity index 100% rename from src/lib/components/layout/language-selector/JP.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/JP.svelte diff --git a/src/lib/components/layout/language-selector/Selector.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/Selector.svelte similarity index 100% rename from src/lib/components/layout/language-selector/Selector.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/Selector.svelte diff --git a/src/lib/components/layout/language-selector/index.ts b/apps/sveltekit/preline/src/lib/components/layout/language-selector/index.ts similarity index 100% rename from src/lib/components/layout/language-selector/index.ts rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/index.ts diff --git a/src/lib/components/layout/mobile/MobileAvatar.svelte b/apps/sveltekit/preline/src/lib/components/layout/mobile/MobileAvatar.svelte similarity index 100% rename from src/lib/components/layout/mobile/MobileAvatar.svelte rename to apps/sveltekit/preline/src/lib/components/layout/mobile/MobileAvatar.svelte diff --git a/src/lib/components/layout/mobile/MobileMenu.svelte b/apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenu.svelte similarity index 100% rename from src/lib/components/layout/mobile/MobileMenu.svelte rename to apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenu.svelte diff --git a/src/lib/components/layout/mobile/MobileMenuBar.svelte b/apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenuBar.svelte similarity index 100% rename from src/lib/components/layout/mobile/MobileMenuBar.svelte rename to apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenuBar.svelte diff --git a/src/lib/components/layout/mobile/MobileMenuToggle.svelte b/apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenuToggle.svelte similarity index 100% rename from src/lib/components/layout/mobile/MobileMenuToggle.svelte rename to apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenuToggle.svelte diff --git a/src/lib/components/ui/forms/CenteredPanel.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/CenteredPanel.svelte similarity index 100% rename from src/lib/components/ui/forms/CenteredPanel.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/CenteredPanel.svelte diff --git a/src/lib/components/ui/forms/Checkbox.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/Checkbox.svelte similarity index 100% rename from src/lib/components/ui/forms/Checkbox.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/Checkbox.svelte diff --git a/src/lib/components/ui/forms/Divider.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/Divider.svelte similarity index 100% rename from src/lib/components/ui/forms/Divider.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/Divider.svelte diff --git a/src/lib/components/ui/forms/FormErrors.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/FormErrors.svelte similarity index 100% rename from src/lib/components/ui/forms/FormErrors.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/FormErrors.svelte diff --git a/src/lib/components/ui/forms/Heading.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/Heading.svelte similarity index 100% rename from src/lib/components/ui/forms/Heading.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/Heading.svelte diff --git a/src/lib/components/ui/forms/InputEmail.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/InputEmail.svelte similarity index 100% rename from src/lib/components/ui/forms/InputEmail.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/InputEmail.svelte diff --git a/src/lib/components/ui/forms/InputText.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/InputText.svelte similarity index 100% rename from src/lib/components/ui/forms/InputText.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/InputText.svelte diff --git a/src/lib/components/ui/forms/MultiFieldPIN.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/MultiFieldPIN.svelte similarity index 100% rename from src/lib/components/ui/forms/MultiFieldPIN.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/MultiFieldPIN.svelte diff --git a/src/lib/components/ui/forms/PoweredBy.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/PoweredBy.svelte similarity index 100% rename from src/lib/components/ui/forms/PoweredBy.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/PoweredBy.svelte diff --git a/src/lib/components/ui/forms/SingleFieldPIN.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/SingleFieldPIN.svelte similarity index 100% rename from src/lib/components/ui/forms/SingleFieldPIN.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/SingleFieldPIN.svelte diff --git a/src/lib/components/ui/forms/SubHeading.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/SubHeading.svelte similarity index 100% rename from src/lib/components/ui/forms/SubHeading.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/SubHeading.svelte diff --git a/src/lib/components/ui/forms/SubmitButton.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/SubmitButton.svelte similarity index 100% rename from src/lib/components/ui/forms/SubmitButton.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/SubmitButton.svelte diff --git a/src/lib/components/ui/forms/index.ts b/apps/sveltekit/preline/src/lib/components/ui/forms/index.ts similarity index 100% rename from src/lib/components/ui/forms/index.ts rename to apps/sveltekit/preline/src/lib/components/ui/forms/index.ts diff --git a/src/lib/components/ui/forms/utils.ts b/apps/sveltekit/preline/src/lib/components/ui/forms/utils.ts similarity index 100% rename from src/lib/components/ui/forms/utils.ts rename to apps/sveltekit/preline/src/lib/components/ui/forms/utils.ts diff --git a/src/lib/components/ui/social/Apple.svelte b/apps/sveltekit/preline/src/lib/components/ui/social/Apple.svelte similarity index 100% rename from src/lib/components/ui/social/Apple.svelte rename to apps/sveltekit/preline/src/lib/components/ui/social/Apple.svelte diff --git a/src/lib/components/ui/social/Google.svelte b/apps/sveltekit/preline/src/lib/components/ui/social/Google.svelte similarity index 100% rename from src/lib/components/ui/social/Google.svelte rename to apps/sveltekit/preline/src/lib/components/ui/social/Google.svelte diff --git a/src/lib/components/ui/social/index.ts b/apps/sveltekit/preline/src/lib/components/ui/social/index.ts similarity index 100% rename from src/lib/components/ui/social/index.ts rename to apps/sveltekit/preline/src/lib/components/ui/social/index.ts diff --git a/src/lib/routes.ts b/apps/sveltekit/preline/src/lib/routes.ts similarity index 96% rename from src/lib/routes.ts rename to apps/sveltekit/preline/src/lib/routes.ts index 2b088b5..5bd7beb 100644 --- a/src/lib/routes.ts +++ b/apps/sveltekit/preline/src/lib/routes.ts @@ -4,7 +4,7 @@ */ import { resolveRoute } from '$app/paths' -import type RouteMetadata from '../../.svelte-kit/types/route_meta_data.json' +import type RouteMetadata from '../../route_meta_data.json' type RouteMetadata = typeof RouteMetadata type Prettify = { [K in keyof T]: T[K] } & {} diff --git a/src/lib/schemas.ts b/apps/sveltekit/preline/src/lib/schemas.ts similarity index 100% rename from src/lib/schemas.ts rename to apps/sveltekit/preline/src/lib/schemas.ts diff --git a/src/lib/server/auth.ts b/apps/sveltekit/preline/src/lib/server/auth.ts similarity index 100% rename from src/lib/server/auth.ts rename to apps/sveltekit/preline/src/lib/server/auth.ts diff --git a/src/lib/server/db.ts b/apps/sveltekit/preline/src/lib/server/db.ts similarity index 100% rename from src/lib/server/db.ts rename to apps/sveltekit/preline/src/lib/server/db.ts diff --git a/src/routes/(app)/+layout.server.ts b/apps/sveltekit/preline/src/routes/(app)/+layout.server.ts similarity index 100% rename from src/routes/(app)/+layout.server.ts rename to apps/sveltekit/preline/src/routes/(app)/+layout.server.ts diff --git a/src/routes/(app)/+layout.svelte b/apps/sveltekit/preline/src/routes/(app)/+layout.svelte similarity index 100% rename from src/routes/(app)/+layout.svelte rename to apps/sveltekit/preline/src/routes/(app)/+layout.svelte diff --git a/src/routes/(app)/app/+page.svelte b/apps/sveltekit/preline/src/routes/(app)/app/+page.svelte similarity index 100% rename from src/routes/(app)/app/+page.svelte rename to apps/sveltekit/preline/src/routes/(app)/app/+page.svelte diff --git a/src/routes/(other)/+layout.server.ts b/apps/sveltekit/preline/src/routes/(other)/+layout.server.ts similarity index 100% rename from src/routes/(other)/+layout.server.ts rename to apps/sveltekit/preline/src/routes/(other)/+layout.server.ts diff --git a/src/routes/(other)/+layout.svelte b/apps/sveltekit/preline/src/routes/(other)/+layout.svelte similarity index 100% rename from src/routes/(other)/+layout.svelte rename to apps/sveltekit/preline/src/routes/(other)/+layout.svelte diff --git a/src/routes/(other)/+page.server.ts b/apps/sveltekit/preline/src/routes/(other)/+page.server.ts similarity index 100% rename from src/routes/(other)/+page.server.ts rename to apps/sveltekit/preline/src/routes/(other)/+page.server.ts diff --git a/src/routes/(other)/+page.svelte b/apps/sveltekit/preline/src/routes/(other)/+page.svelte similarity index 100% rename from src/routes/(other)/+page.svelte rename to apps/sveltekit/preline/src/routes/(other)/+page.svelte diff --git a/src/routes/(other)/login/+page.server.ts b/apps/sveltekit/preline/src/routes/(other)/login/+page.server.ts similarity index 100% rename from src/routes/(other)/login/+page.server.ts rename to apps/sveltekit/preline/src/routes/(other)/login/+page.server.ts diff --git a/src/routes/(other)/login/+page.svelte b/apps/sveltekit/preline/src/routes/(other)/login/+page.svelte similarity index 100% rename from src/routes/(other)/login/+page.svelte rename to apps/sveltekit/preline/src/routes/(other)/login/+page.svelte diff --git a/src/routes/(other)/logout/+page.server.ts b/apps/sveltekit/preline/src/routes/(other)/logout/+page.server.ts similarity index 100% rename from src/routes/(other)/logout/+page.server.ts rename to apps/sveltekit/preline/src/routes/(other)/logout/+page.server.ts diff --git a/src/routes/(other)/verify-email/awaiting-link/+page.svelte b/apps/sveltekit/preline/src/routes/(other)/verify-email/awaiting-link/+page.svelte similarity index 100% rename from src/routes/(other)/verify-email/awaiting-link/+page.svelte rename to apps/sveltekit/preline/src/routes/(other)/verify-email/awaiting-link/+page.svelte diff --git a/src/routes/(other)/verify-email/code/+page.server.ts b/apps/sveltekit/preline/src/routes/(other)/verify-email/code/+page.server.ts similarity index 100% rename from src/routes/(other)/verify-email/code/+page.server.ts rename to apps/sveltekit/preline/src/routes/(other)/verify-email/code/+page.server.ts diff --git a/src/routes/(other)/verify-email/code/+page.svelte b/apps/sveltekit/preline/src/routes/(other)/verify-email/code/+page.svelte similarity index 100% rename from src/routes/(other)/verify-email/code/+page.svelte rename to apps/sveltekit/preline/src/routes/(other)/verify-email/code/+page.svelte diff --git a/src/routes/(other)/verify-email/link/+page.server.ts b/apps/sveltekit/preline/src/routes/(other)/verify-email/link/+page.server.ts similarity index 100% rename from src/routes/(other)/verify-email/link/+page.server.ts rename to apps/sveltekit/preline/src/routes/(other)/verify-email/link/+page.server.ts diff --git a/src/routes/(other)/verify-email/link/+page.svelte b/apps/sveltekit/preline/src/routes/(other)/verify-email/link/+page.svelte similarity index 100% rename from src/routes/(other)/verify-email/link/+page.svelte rename to apps/sveltekit/preline/src/routes/(other)/verify-email/link/+page.svelte diff --git a/static/android-chrome-192x192.png b/apps/sveltekit/preline/static/android-chrome-192x192.png similarity index 100% rename from static/android-chrome-192x192.png rename to apps/sveltekit/preline/static/android-chrome-192x192.png diff --git a/static/android-chrome-512x512.png b/apps/sveltekit/preline/static/android-chrome-512x512.png similarity index 100% rename from static/android-chrome-512x512.png rename to apps/sveltekit/preline/static/android-chrome-512x512.png diff --git a/static/apple-touch-icon.png b/apps/sveltekit/preline/static/apple-touch-icon.png similarity index 100% rename from static/apple-touch-icon.png rename to apps/sveltekit/preline/static/apple-touch-icon.png diff --git a/static/browserconfig.xml b/apps/sveltekit/preline/static/browserconfig.xml similarity index 100% rename from static/browserconfig.xml rename to apps/sveltekit/preline/static/browserconfig.xml diff --git a/static/favicon-16x16.png b/apps/sveltekit/preline/static/favicon-16x16.png similarity index 100% rename from static/favicon-16x16.png rename to apps/sveltekit/preline/static/favicon-16x16.png diff --git a/static/favicon-32x32.png b/apps/sveltekit/preline/static/favicon-32x32.png similarity index 100% rename from static/favicon-32x32.png rename to apps/sveltekit/preline/static/favicon-32x32.png diff --git a/static/favicon.ico b/apps/sveltekit/preline/static/favicon.ico similarity index 100% rename from static/favicon.ico rename to apps/sveltekit/preline/static/favicon.ico diff --git a/static/mstile-144x144.png b/apps/sveltekit/preline/static/mstile-144x144.png similarity index 100% rename from static/mstile-144x144.png rename to apps/sveltekit/preline/static/mstile-144x144.png diff --git a/static/mstile-150x150.png b/apps/sveltekit/preline/static/mstile-150x150.png similarity index 100% rename from static/mstile-150x150.png rename to apps/sveltekit/preline/static/mstile-150x150.png diff --git a/static/mstile-310x150.png b/apps/sveltekit/preline/static/mstile-310x150.png similarity index 100% rename from static/mstile-310x150.png rename to apps/sveltekit/preline/static/mstile-310x150.png diff --git a/static/mstile-310x310.png b/apps/sveltekit/preline/static/mstile-310x310.png similarity index 100% rename from static/mstile-310x310.png rename to apps/sveltekit/preline/static/mstile-310x310.png diff --git a/static/mstile-70x70.png b/apps/sveltekit/preline/static/mstile-70x70.png similarity index 100% rename from static/mstile-70x70.png rename to apps/sveltekit/preline/static/mstile-70x70.png diff --git a/static/repo-banner.dark.svg b/apps/sveltekit/preline/static/repo-banner.dark.svg similarity index 100% rename from static/repo-banner.dark.svg rename to apps/sveltekit/preline/static/repo-banner.dark.svg diff --git a/static/repo-banner.svg b/apps/sveltekit/preline/static/repo-banner.svg similarity index 100% rename from static/repo-banner.svg rename to apps/sveltekit/preline/static/repo-banner.svg diff --git a/static/robots.txt b/apps/sveltekit/preline/static/robots.txt similarity index 100% rename from static/robots.txt rename to apps/sveltekit/preline/static/robots.txt diff --git a/static/safari-pinned-tab.svg b/apps/sveltekit/preline/static/safari-pinned-tab.svg similarity index 100% rename from static/safari-pinned-tab.svg rename to apps/sveltekit/preline/static/safari-pinned-tab.svg diff --git a/static/site.webmanifest b/apps/sveltekit/preline/static/site.webmanifest similarity index 100% rename from static/site.webmanifest rename to apps/sveltekit/preline/static/site.webmanifest diff --git a/svelte.config.js b/apps/sveltekit/preline/svelte.config.js similarity index 100% rename from svelte.config.js rename to apps/sveltekit/preline/svelte.config.js diff --git a/tailwind.config.cjs b/apps/sveltekit/preline/tailwind.config.cjs similarity index 100% rename from tailwind.config.cjs rename to apps/sveltekit/preline/tailwind.config.cjs diff --git a/tsconfig.json b/apps/sveltekit/preline/tsconfig.json similarity index 100% rename from tsconfig.json rename to apps/sveltekit/preline/tsconfig.json diff --git a/vite.config.ts b/apps/sveltekit/preline/vite.config.ts similarity index 100% rename from vite.config.ts rename to apps/sveltekit/preline/vite.config.ts diff --git a/apps/sveltekit/shadcn/.env.example b/apps/sveltekit/shadcn/.env.example new file mode 100644 index 0000000..ef0b138 --- /dev/null +++ b/apps/sveltekit/shadcn/.env.example @@ -0,0 +1,27 @@ +# See https://console.passlock.dev/settings +PUBLIC_PASSLOCK_TENANCY_ID = '' + +# See https://console.passlock.dev/settings +PUBLIC_PASSLOCK_CLIENT_ID = '' + +# See https://console.passlock.dev/apikeys +PASSLOCK_API_KEY = '' + +# Use https://api.passlock.dev +PUBLIC_PASSLOCK_ENDPOINT = 'https://api.passlock.dev' + +# See https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid#get_your_google_api_client_id +# leave empty if not using Google sign in +PUBLIC_GOOGLE_CLIENT_ID = '' + +# You need an App ID with 'Sign In with Apple' enabled. +# You also need a Service ID, also configured with 'Sign In With Apple' +# Confusingly a Service ID is also known as a Client ID in the Apple docs +# leave empty if not using Sign In with Apple +PUBLIC_APPLE_CLIENT_ID = '' + +# We don't actually use redirects as we use the modal popup +# however Apple still requires us to pass a redirect url +# during authentication, and it must match one of the Website URLs +# the Service ID was configured with +PUBLIC_APPLE_REDIRECT_URL = '' \ No newline at end of file diff --git a/apps/sveltekit/shadcn/.gitignore b/apps/sveltekit/shadcn/.gitignore new file mode 100644 index 0000000..0aca93e --- /dev/null +++ b/apps/sveltekit/shadcn/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +sqlite.db +TODO.txt +.sst \ No newline at end of file diff --git a/apps/sveltekit/shadcn/.npmrc b/apps/sveltekit/shadcn/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/apps/sveltekit/shadcn/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/apps/sveltekit/shadcn/.prettierignore b/apps/sveltekit/shadcn/.prettierignore new file mode 100644 index 0000000..cc41cea --- /dev/null +++ b/apps/sveltekit/shadcn/.prettierignore @@ -0,0 +1,4 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/apps/sveltekit/shadcn/.prettierrc b/apps/sveltekit/shadcn/.prettierrc new file mode 100644 index 0000000..d308775 --- /dev/null +++ b/apps/sveltekit/shadcn/.prettierrc @@ -0,0 +1,13 @@ +{ + "useTabs": false, + "tabWidth": 2, + "trailingComma": "none", + "printWidth": 80, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }], + "htmlWhitespaceSensitivity": "ignore", + "bracketSameLine": true, + "semi": false, + "singleQuote": true, + "arrowParens": "avoid" +} diff --git a/apps/sveltekit/shadcn/LICENSE b/apps/sveltekit/shadcn/LICENSE new file mode 100644 index 0000000..0b9c381 --- /dev/null +++ b/apps/sveltekit/shadcn/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Passlock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/sveltekit/shadcn/components.json b/apps/sveltekit/shadcn/components.json new file mode 100644 index 0000000..892c9d6 --- /dev/null +++ b/apps/sveltekit/shadcn/components.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "style": "default", + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app.pcss", + "baseColor": "slate" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils" + }, + "typescript": true +} diff --git a/apps/sveltekit/shadcn/docs/intro.md b/apps/sveltekit/shadcn/docs/intro.md new file mode 100644 index 0000000..1c2f836 --- /dev/null +++ b/apps/sveltekit/shadcn/docs/intro.md @@ -0,0 +1,125 @@ +# Developer documentation + +If something is not clear or you run into problems please file an [issue][issues] and I'll update the docs accordingly. + +# The frameworks + +A quick overview of the frameworks and how they are used. + +## Passlock + +[Passlock][passlock] handles passkey registration and authentication. + +Passlock also handles social sign in, abstracting passkey and google authentication users into a common _"Principal"_. The abstraction allows this app to use the same code to handle both passkey and social authentication. + +During registration and authentication, Passlock returns a token. We send this token to the backend actions and verify it's authenticity via the Passlock REST API. + +## Lucia + +[Lucia][lucia] handles sessions. Put simply, when a user authenticates, Passlock returns a token. A backend `+page.server.ts` action verifies the token is authentic, then creates a Lucia session. Lucia supports many database backends, this example uses sqlite. + +## Superforms + +[Superforms][superforms] makes it really easy to handle forms. We use Superforms for both the registration and authentication forms. Passkey registration & authentication hooks into Superforms events (see below). + +## shadcn-svelte + +Shadcn/ui is a collection of tailwind components. [Shadcn-svelte][shadcn-svelte] is a excellent Svelte port, built on top of [bits-ui][bitsui] (which is itself built on Melt UI). + +So you have a choice in styling between Shadcn/ui or Preline. Ultimately the behavior is handled by Melt UI. + +# The code + +The code is quite well commented so please check it out. I'll give a quite overview of the operations, with pointers to the relevant source code. + +## Passkey registration + +See [src/routes/(other)/+page.svelte](<../src/routes/(other)/+page.svelte>). The basic approach is to use Superforms events: + +1. User completes and submits a form. +2. Superforms intercepts the submission +3. We ask the Passlock library to create a passkey +4. This returns a token, representing the new passkey +5. We attach this token to the form and submit it +6. In the [src/routes/(other)/+page.server.ts](<../src/routes/(other)/+page.server.ts>) action we verify the token by exchanging it for a Principal +7. This principal includes a user id +8. We create a new local user and link the user id + +## Passkey authentication + +See [src/routes/(other)/login/+page.svelte](<../src/routes/(other)/login/+page.svelte>). Very similar to the registration: + +1. User completes and submits a form. +2. Superforms intercepts the submission +3. We ask the Passlock library to authenticate using a passkey +4. This returns a token, representing the passkey authentication +5. Attach this token to the form and submit it +6. In the [src/routes/(other)/login/+page.server.ts](<../src/routes/(other)/login/+page.server.ts>) action we verify the token by exchanging it for a Principal +7. This principal includes a user id +8. Lookup the local user by id and create a new Lucia session + +## Social sign in + +Similar conceptually, the heaby lifting is offloaded to Passlock. We just need to deal with the token (and some UX stuff) + +1. User clicks the Apple/Google button (or one tap) +2. Apple/Google authenticates the user +3. We ask the Passlock library to process their response +4. This call returns a token, representing the user +5. As for passkey registration/authentication + +## Hooks + +We want to protect the `/app` route. We do this in [src/hooks.server.ts](../src/hooks.server.ts) by checking the route id and user/session status. + +## Mailbox verification + +During the `registerPasskey()` call you can pass a `verifyEmail` option: + +```typescript +// email a code +const verifyEmail = { + method: 'code' +} + +// email a link +const verifyEmail = { + method: 'link', + redirectUrl: 'http://localhost:5174/verify-email/link' +} + +// verifyEmail can also be undefined in which case +// no verification mails will be sent +passlock.registerPasskey({ verifyEmail }) +``` + +Passlock will generate a secure code or link and email it to the user during the passkey registration. The [src/routes/(other)/+page.server.ts](<../src/routes/(other)/+page.server.ts>) action then redirects the user to one of two pages: + +1. /verify-email/awaiting-link - Prompts the user to check their emails +2. /verify-email/code - Prompts the user to check their emails and enter the code + +### Verifying via a link + +If the `verifyEmail` method is `link` you must also provide a url to which the user will be sent when they click the link. You should use the route `/verify-email/link`. + +Passlock will send the user to this route, appending a `?code=xxx` query parameter. In the [src/routes/(other)/verify-email/link/+page.server.ts]() load function we grab this code and feed it into [src/routes/(other)/verify-email/link/+page.svelte](). This page presents the user with a button. When the button is clicked we call Passlock to verify the code is authentic. + +> [!TIP] +> Why do it this way? Why not simply verify the code in the +page.server.ts load function? Because we may need to re-authenticate the user. For background please see the [passlock docs](https://docs.passlock.dev/docs/howto/verify-emails#re-authenticating-the-user) + +[passlock]: https://passlock.dev +[lucia]: https://lucia-auth.com +[tailwind]: https://tailwindcss.com +[preline]: https://preline.co +[meltui]: https://melt-ui.com +[bitsui]: https://www.bits-ui.com/docs/introduction +[shadcn-svelte]: https://www.shadcn-svelte.com +[passlock-signup]: https://console.passlock.dev/register +[passlock-console]: https://console.passlock.dev +[passlock-settings]: https://console.passlock.dev/settings +[passlock-apikeys]: https://console.passlock.dev/apikeys +[google-signin]: https://developers.google.com/identity/gsi/web/guides/overview +[google-client-id]: https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid#get_your_google_api_client_id +[issues]: https://github.com/passlock-dev/svelte-passkeys/issues +[superforms]: https://superforms.rocks +[apple-verification-codes]: https://www.cultofmac.com/819421/ios-17-autofill-verification-codes-safari-mail-app/ diff --git a/apps/sveltekit/shadcn/package.json b/apps/sveltekit/shadcn/package.json new file mode 100644 index 0000000..3f38f36 --- /dev/null +++ b/apps/sveltekit/shadcn/package.json @@ -0,0 +1,57 @@ +{ + "name": "sveltekit", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --host", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:errors": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold error", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "typecheck": "pnpm run check:errors", + "format": "prettier --write .", + "ncu": "ncu --peer -x @passlock/sveltekit -x better-sqlite3 -x postcss-load-config", + "ncu:save": "ncu --peer -x @passlock/sveltekit -x better-sqlite3 -x postcss-load-config -u", + "pnpm:upgrade": "corepack use pnpm@latest" + }, + "devDependencies": { + "@lucia-auth/adapter-sqlite": "^3.0.2", + "@melt-ui/pp": "^0.3.2", + "@melt-ui/svelte": "^0.83.0", + "@passlock/sveltekit": "0.9.20", + "@sveltejs/adapter-auto": "^3.2.2", + "@sveltejs/kit": "^2.5.18", + "@sveltejs/vite-plugin-svelte": "^3.1.1", + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.7", + "@types/better-sqlite3": "^7.6.11", + "@types/google-one-tap": "^1.2.6", + "autoprefixer": "^10.4.19", + "better-sqlite3": "~9.6.0", + "bits-ui": "^0.21.12", + "clsx": "^2.1.1", + "dedent": "^1.5.3", + "js-sha256": "^0.11.0", + "lucia": "^3.2.0", + "lucide-svelte": "^0.399.0", + "mode-watcher": "^0.3.1", + "node-check-updates": "^0.1.9", + "postcss": "^8.4.39", + "postcss-load-config": "^5.1.0", + "prettier": "^3.3.2", + "prettier-plugin-svelte": "^3.2.5", + "svelte": "^4.2.18", + "svelte-check": "^3.8.4", + "sveltekit-superforms": "^2.16.0", + "tailwind-merge": "^2.4.0", + "tailwind-variants": "^0.2.1", + "tailwindcss": "^3.4.4", + "tslib": "^2.6.3", + "typescript": "^5.5.3", + "valibot": "^0.36.0", + "vite": "^5.3.3" + }, + "packageManager": "pnpm@9.5.0+sha256.dbdf5961c32909fb030595a9daa1dae720162e658609a8f92f2fa99835510ca5" +} diff --git a/apps/sveltekit/shadcn/postcss.config.cjs b/apps/sveltekit/shadcn/postcss.config.cjs new file mode 100644 index 0000000..e9adcdd --- /dev/null +++ b/apps/sveltekit/shadcn/postcss.config.cjs @@ -0,0 +1,13 @@ +const tailwindcss = require('tailwindcss') +const autoprefixer = require('autoprefixer') + +const config = { + plugins: [ + //Some plugins, like tailwindcss/nesting, need to run before Tailwind, + tailwindcss(), + //But others, like autoprefixer, need to run after, + autoprefixer + ] +} + +module.exports = config diff --git a/apps/sveltekit/shadcn/src/app.d.ts b/apps/sveltekit/shadcn/src/app.d.ts new file mode 100644 index 0000000..c573252 --- /dev/null +++ b/apps/sveltekit/shadcn/src/app.d.ts @@ -0,0 +1,20 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + interface Locals { + user: import('lucia').User | undefined + session: import('lucia').Session | undefined + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +declare global { + const google: typeof import('google-one-tap') +} + +export {} diff --git a/apps/sveltekit/shadcn/src/app.html b/apps/sveltekit/shadcn/src/app.html new file mode 100644 index 0000000..3a963e5 --- /dev/null +++ b/apps/sveltekit/shadcn/src/app.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/sveltekit/shadcn/src/app.pcss b/apps/sveltekit/shadcn/src/app.pcss new file mode 100644 index 0000000..d5d1389 --- /dev/null +++ b/apps/sveltekit/shadcn/src/app.pcss @@ -0,0 +1,78 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 72.2% 50.6%; + --destructive-foreground: 210 40% 98%; + + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --ring: hsl(212.7, 26.8%, 83.9); + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/sveltekit/shadcn/src/hooks.server.ts b/apps/sveltekit/shadcn/src/hooks.server.ts new file mode 100644 index 0000000..059d023 --- /dev/null +++ b/apps/sveltekit/shadcn/src/hooks.server.ts @@ -0,0 +1,51 @@ +import { lucia } from '$lib/server/auth' +import { initLucia } from '$lib/server/db' +import { redirect, type Handle } from '@sveltejs/kit' + +const isProtectedRoute = (routeId: string | null) => + routeId?.startsWith('/(app)') + +export const handle: Handle = async ({ event, resolve }) => { + const sessionId = event.cookies.get(lucia.sessionCookieName) + + if (!sessionId && isProtectedRoute(event.route.id)) { + return redirect(302, '/') + } else if (!sessionId) { + event.locals.user = undefined + event.locals.session = undefined + return resolve(event) + } + + const { session, user } = await lucia.validateSession(sessionId) + + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id) + // sveltekit types deviates from the de-facto standard + // you can use 'as any' too + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: '/', + ...sessionCookie.attributes + }) + } + + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie() + event.cookies.set(sessionCookie.name, sessionCookie.value, { + path: '/', + ...sessionCookie.attributes + }) + } + + event.locals.user = user || undefined + event.locals.session = session || undefined + + if (isProtectedRoute(event.route.id) && event.locals.user) { + return resolve(event) + } else if (isProtectedRoute(event.route.id)) { + redirect(302, '/') + } else { + return resolve(event) + } +} + +initLucia() diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/apple.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/apple.svelte new file mode 100644 index 0000000..dbbc056 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/apple.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/aria.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/aria.svelte new file mode 100644 index 0000000..ff4e2c3 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/aria.svelte @@ -0,0 +1,10 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/field-error.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/field-error.svelte new file mode 100644 index 0000000..3f82840 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/field-error.svelte @@ -0,0 +1,20 @@ + + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/github.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/github.svelte new file mode 100644 index 0000000..223c53d --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/github.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/google.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/google.svelte new file mode 100644 index 0000000..12ac1bc --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/google.svelte @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/hamburger.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/hamburger.svelte new file mode 100644 index 0000000..380b2df --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/hamburger.svelte @@ -0,0 +1,32 @@ + + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/index.ts b/apps/sveltekit/shadcn/src/lib/components/icons/index.ts new file mode 100644 index 0000000..5c67a65 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/index.ts @@ -0,0 +1,88 @@ +import ArrowRight from 'lucide-svelte/icons/arrow-right' +import Check from 'lucide-svelte/icons/check' +import ChevronLeft from 'lucide-svelte/icons/chevron-left' +import ChevronRight from 'lucide-svelte/icons/chevron-right' +import CircleHelp from 'lucide-svelte/icons/circle-help' +import ClipboardCheck from 'lucide-svelte/icons/clipboard-check' +import Copy from 'lucide-svelte/icons/copy' +import CreditCard from 'lucide-svelte/icons/credit-card' +import EllipsisVertical from 'lucide-svelte/icons/ellipsis' +import File from 'lucide-svelte/icons/file' +import FileText from 'lucide-svelte/icons/file-text' +import Image from 'lucide-svelte/icons/image' +import Laptop from 'lucide-svelte/icons/laptop' +import Loader from 'lucide-svelte/icons/loader' +import Mail from 'lucide-svelte/icons/mail' +import Moon from 'lucide-svelte/icons/moon' +import Pizza from 'lucide-svelte/icons/pizza' +import Plus from 'lucide-svelte/icons/plus' +import Settings from 'lucide-svelte/icons/settings' +import SunMedium from 'lucide-svelte/icons/sun-medium' +import Trash from 'lucide-svelte/icons/trash' +import { + default as FieldError, + default as TriangleAlert +} from 'lucide-svelte/icons/triangle-alert' +import User from 'lucide-svelte/icons/user' +import X from 'lucide-svelte/icons/x' + +import type { SvelteComponent } from 'svelte' +import Apple from './apple.svelte' +import Aria from './aria.svelte' +import GitHub from './github.svelte' +import Google from './google.svelte' +import Hamburger from './hamburger.svelte' +import Logo from './logo.svelte' +import Npm from './npm.svelte' +import Passkey from './passkey.svelte' +import PayPal from './paypal.svelte' +import Pnpm from './pnpm.svelte' +import RadixSvelte from './radix-svelte.svelte' +import Tailwind from './tailwind.svelte' +import Twitter from './twitter.svelte' +import Yarn from './yarn.svelte' + +export type Icon = SvelteComponent + +export { + Apple, + Aria, + ArrowRight, + Check, + ChevronLeft, + ChevronRight, + CircleHelp, + ClipboardCheck, + Copy, + CreditCard, + EllipsisVertical, + FieldError, + File, + FileText, + GitHub, + Google, + Hamburger, + Image, + Laptop, + Loader, + Logo, + Mail, + Moon, + Npm, + Passkey, + PayPal, + Pizza, + Plus, + Pnpm, + RadixSvelte, + Settings, + Loader as Spinner, + SunMedium, + Tailwind, + Trash, + TriangleAlert, + Twitter, + User, + X, + Yarn +} diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/logo.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/logo.svelte new file mode 100644 index 0000000..eb7925e --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/logo.svelte @@ -0,0 +1,29 @@ + + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/npm.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/npm.svelte new file mode 100644 index 0000000..b2a8279 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/npm.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/passkey.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/passkey.svelte new file mode 100644 index 0000000..c21a47a --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/passkey.svelte @@ -0,0 +1,12 @@ + + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/paypal.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/paypal.svelte new file mode 100644 index 0000000..04cdef0 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/paypal.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/pnpm.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/pnpm.svelte new file mode 100644 index 0000000..2d75ab1 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/pnpm.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/radix-svelte.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/radix-svelte.svelte new file mode 100644 index 0000000..23d7048 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/radix-svelte.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/radix.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/radix.svelte new file mode 100644 index 0000000..cfaa147 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/radix.svelte @@ -0,0 +1,15 @@ + + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/svelte-logo.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/svelte-logo.svelte new file mode 100644 index 0000000..e071333 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/svelte-logo.svelte @@ -0,0 +1,8 @@ + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/tailwind.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/tailwind.svelte new file mode 100644 index 0000000..0af047a --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/tailwind.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/twitter.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/twitter.svelte new file mode 100644 index 0000000..a759f76 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/twitter.svelte @@ -0,0 +1,10 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/icons/yarn.svelte b/apps/sveltekit/shadcn/src/lib/components/icons/yarn.svelte new file mode 100644 index 0000000..9d4bc6c --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/icons/yarn.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/theme/Selector.svelte b/apps/sveltekit/shadcn/src/lib/components/theme/Selector.svelte new file mode 100644 index 0000000..8901849 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/theme/Selector.svelte @@ -0,0 +1,31 @@ + + + + + + + + setMode('light')}> + Light + + setMode('dark')}>Dark + resetMode()}>System + + diff --git a/apps/sveltekit/shadcn/src/lib/components/theme/index.ts b/apps/sveltekit/shadcn/src/lib/components/theme/index.ts new file mode 100644 index 0000000..61e3c90 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/theme/index.ts @@ -0,0 +1,3 @@ +import Selector from './Selector.svelte' + +export { Selector as ThemeSelector } diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/avatar/avatar-fallback.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 0000000..4e32bd5 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/avatar/avatar-image.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 0000000..afc2049 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/avatar/avatar.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 0000000..49de7e3 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/avatar/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/avatar/index.ts new file mode 100644 index 0000000..2434ba1 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/avatar/index.ts @@ -0,0 +1,13 @@ +import Fallback from './avatar-fallback.svelte' +import Image from './avatar-image.svelte' +import Root from './avatar.svelte' + +export { + // + Root as Avatar, + Fallback as AvatarFallback, + Image as AvatarImage, + Fallback, + Image, + Root +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/badge/badge.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..427603d --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/badge/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..df66174 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/badge/index.ts @@ -0,0 +1,22 @@ +import { tv, type VariantProps } from 'tailwind-variants' +export { default as Badge } from './badge.svelte' + +export const badgeVariants = tv({ + base: 'inline-flex select-none 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 type Variant = VariantProps['variant'] diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/banner/Banner.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/banner/Banner.svelte new file mode 100644 index 0000000..25bc903 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/banner/Banner.svelte @@ -0,0 +1,36 @@ + + +{#if visible} +
+
+
+
 
+ + + + +
+
+
+{/if} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/banner/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/banner/index.ts new file mode 100644 index 0000000..dd34cab --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/banner/index.ts @@ -0,0 +1,3 @@ +import Banner from './Banner.svelte' + +export default Banner diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte new file mode 100644 index 0000000..fadca9a --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte @@ -0,0 +1,23 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte new file mode 100644 index 0000000..8f7d0bc --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte @@ -0,0 +1,16 @@ + + +
  • + +
  • diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte new file mode 100644 index 0000000..0aa9b7a --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte @@ -0,0 +1,31 @@ + + +{#if asChild} + +{:else} + + + +{/if} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte new file mode 100644 index 0000000..797787b --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte @@ -0,0 +1,22 @@ + + +
      + +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte new file mode 100644 index 0000000..2c66bb8 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte new file mode 100644 index 0000000..7e12542 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte @@ -0,0 +1,24 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb.svelte new file mode 100644 index 0000000..26d7b98 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/breadcrumb.svelte @@ -0,0 +1,15 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/index.ts new file mode 100644 index 0000000..cbaf924 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/breadcrumb/index.ts @@ -0,0 +1,25 @@ +import Ellipsis from './breadcrumb-ellipsis.svelte' +import Item from './breadcrumb-item.svelte' +import Link from './breadcrumb-link.svelte' +import List from './breadcrumb-list.svelte' +import Page from './breadcrumb-page.svelte' +import Separator from './breadcrumb-separator.svelte' +import Root from './breadcrumb.svelte' + +export { + // + Root as Breadcrumb, + Ellipsis as BreadcrumbEllipsis, + Item as BreadcrumbItem, + Link as BreadcrumbLink, + List as BreadcrumbList, + Page as BreadcrumbPage, + Separator as BreadcrumbSeparator, + Ellipsis, + Item, + Link, + List, + Page, + Root, + Separator +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/button/button.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..e6fc217 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/button/button.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/button/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..1dac651 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/button/index.ts @@ -0,0 +1,50 @@ +import type { Button as ButtonPrimitive } from 'bits-ui' +import { tv, type VariantProps } from 'tailwind-variants' +import Root from './button.svelte' + +const buttonVariants = tv({ + base: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline' + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } +}) + +type Variant = VariantProps['variant'] +type Size = VariantProps['size'] + +type Props = ButtonPrimitive.Props & { + variant?: Variant + size?: Size +} + +type Events = ButtonPrimitive.Events + +export { + // + Root as Button, + Root, + buttonVariants, + type Events as ButtonEvents, + type Props as ButtonProps, + type Events, + type Props +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/card/card-content.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..8efb98a --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,13 @@ + + +
    + +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/card/card-description.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..8b580be --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,13 @@ + + +

    + +

    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/card/card-footer.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..0fbeb0f --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,13 @@ + + +
    + +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/card/card-header.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..e7c5eef --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,13 @@ + + +
    + +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/card/card-title.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..425b0b6 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/card/card.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..57977ee --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/card/card.svelte @@ -0,0 +1,18 @@ + + +
    + +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/card/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..ea0f033 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/card/index.ts @@ -0,0 +1,24 @@ +import Content from './card-content.svelte' +import Description from './card-description.svelte' +import Footer from './card-footer.svelte' +import Header from './card-header.svelte' +import Title from './card-title.svelte' +import Root from './card.svelte' + +export { + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Content, + Description, + Footer, + Header, + Root, + Title +} + +export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..5197d13 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..de1e22f --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,26 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..4f46160 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,30 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..c4d9550 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..049125f --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..38e1a19 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..1b0f539 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,13 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..59b51b4 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..d43054a --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..50bd9c6 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,31 @@ + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..5d703ff --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,48 @@ +import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' +import CheckboxItem from './dropdown-menu-checkbox-item.svelte' +import Content from './dropdown-menu-content.svelte' +import Item from './dropdown-menu-item.svelte' +import Label from './dropdown-menu-label.svelte' +import RadioGroup from './dropdown-menu-radio-group.svelte' +import RadioItem from './dropdown-menu-radio-item.svelte' +import Separator from './dropdown-menu-separator.svelte' +import Shortcut from './dropdown-menu-shortcut.svelte' +import SubContent from './dropdown-menu-sub-content.svelte' +import SubTrigger from './dropdown-menu-sub-trigger.svelte' + +const Sub = DropdownMenuPrimitive.Sub +const Root = DropdownMenuPrimitive.Root +const Trigger = DropdownMenuPrimitive.Trigger +const Group = DropdownMenuPrimitive.Group + +export { + CheckboxItem, + Content, + // + Root as DropdownMenu, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + Group, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/forms/checkbox.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/forms/checkbox.svelte new file mode 100644 index 0000000..4a41ff2 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/forms/checkbox.svelte @@ -0,0 +1,86 @@ + + + +
    +
    + + + +
    + + {#if $errors} + {#each $errors as error} +
    {error}
    + {/each} + {/if} + + {#if $$slots.description} +
    + +
    + {/if} +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/forms/divider.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/forms/divider.svelte new file mode 100644 index 0000000..a3d6c94 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/forms/divider.svelte @@ -0,0 +1,18 @@ +
    + Or +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/forms/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/forms/index.ts new file mode 100644 index 0000000..41e3512 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/forms/index.ts @@ -0,0 +1,8 @@ +import Checkbox from './checkbox.svelte' +import Divider from './divider.svelte' +import InputEmail from './inputEmail.svelte' +import InputText from './inputText.svelte' +import PoweredBy from './poweredBy.svelte' +import SubmitButton from './submitButton.svelte' + +export { Checkbox, Divider, InputEmail, InputText, PoweredBy, SubmitButton } diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/forms/inputEmail.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/forms/inputEmail.svelte new file mode 100644 index 0000000..7d5cf4e --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/forms/inputEmail.svelte @@ -0,0 +1,110 @@ + + + +
    + +
    + + + {#if $errors} +
    + +
    + {/if} +
    + + {#if $errors} +
    +
      + {#each $errors as error} +
    • {error}
    • + {/each} +
    +
    + {/if} + + {#if $$slots.description} +
    + +
    + {/if} +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/forms/inputText.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/forms/inputText.svelte new file mode 100644 index 0000000..2dffb9d --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/forms/inputText.svelte @@ -0,0 +1,109 @@ + + + +
    + +
    + + + {#if $errors} +
    + +
    + {/if} +
    + + {#if $errors} +
    +
      + {#each $errors as error} +
    • {error}
    • + {/each} +
    +
    + {/if} + + {#if $$slots.description} +
    + +
    + {/if} +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/forms/poweredBy.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/forms/poweredBy.svelte new file mode 100644 index 0000000..3010ea4 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/forms/poweredBy.svelte @@ -0,0 +1,15 @@ + + +
    + Powered by Passlock +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/forms/submitButton.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/forms/submitButton.svelte new file mode 100644 index 0000000..410f72a --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/forms/submitButton.svelte @@ -0,0 +1,20 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/forms/utils.ts b/apps/sveltekit/shadcn/src/lib/components/ui/forms/utils.ts new file mode 100644 index 0000000..b06f943 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/forms/utils.ts @@ -0,0 +1,14 @@ +export const generateId = (): string => { + let result = '' + + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + const charactersLength = characters.length + + let counter = 0 + while (counter < 5) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)) + counter += 1 + } + + return result +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/input/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..ac83915 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/input/index.ts @@ -0,0 +1,28 @@ +import Root from './input.svelte' + +export type FormInputEvent = T & { + currentTarget: EventTarget & HTMLInputElement +} +export type InputEvents = { + blur: FormInputEvent + change: FormInputEvent + click: FormInputEvent + focus: FormInputEvent + focusin: FormInputEvent + focusout: FormInputEvent + keydown: FormInputEvent + keypress: FormInputEvent + keyup: FormInputEvent + mouseover: FormInputEvent + mouseenter: FormInputEvent + mouseleave: FormInputEvent + paste: FormInputEvent + input: FormInputEvent + wheel: FormInputEvent +} + +export { + // + Root as Input, + Root +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/input/input.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..b318509 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/input/input.svelte @@ -0,0 +1,40 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/label/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..e025fe3 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from './label.svelte' + +export { + // + Root as Label, + Root +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/label/label.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..84a8022 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/logo/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/logo/index.ts new file mode 100644 index 0000000..db991f3 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/logo/index.ts @@ -0,0 +1,3 @@ +import Logo from './logo.svelte' + +export default Logo diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/logo/logo.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/logo/logo.svelte new file mode 100644 index 0000000..21ce18c --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/logo/logo.svelte @@ -0,0 +1,14 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pagination/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/index.ts new file mode 100644 index 0000000..497e4bc --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/index.ts @@ -0,0 +1,25 @@ +import Content from './pagination-content.svelte' +import Ellipsis from './pagination-ellipsis.svelte' +import Item from './pagination-item.svelte' +import Link from './pagination-link.svelte' +import NextButton from './pagination-next-button.svelte' +import PrevButton from './pagination-prev-button.svelte' +import Root from './pagination.svelte' + +export { + Content, + Ellipsis, + Item, + Link, + NextButton, + // + Root as Pagination, + Content as PaginationContent, + Ellipsis as PaginationEllipsis, + Item as PaginationItem, + Link as PaginationLink, + NextButton as PaginationNextButton, + PrevButton as PaginationPrevButton, + PrevButton, + Root +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-content.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-content.svelte new file mode 100644 index 0000000..0e764ae --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-content.svelte @@ -0,0 +1,13 @@ + + +
      + +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-ellipsis.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-ellipsis.svelte new file mode 100644 index 0000000..0b8df89 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-ellipsis.svelte @@ -0,0 +1,18 @@ + + + + + More pages + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-item.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-item.svelte new file mode 100644 index 0000000..5cb8ca5 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-item.svelte @@ -0,0 +1,13 @@ + + +
  • + +
  • diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-link.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-link.svelte new file mode 100644 index 0000000..a193fa0 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-link.svelte @@ -0,0 +1,36 @@ + + + + {page.value} + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-next-button.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-next-button.svelte new file mode 100644 index 0000000..6442590 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-next-button.svelte @@ -0,0 +1,26 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-prev-button.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-prev-button.svelte new file mode 100644 index 0000000..a453e69 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination-prev-button.svelte @@ -0,0 +1,26 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination.svelte new file mode 100644 index 0000000..d0a531e --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pagination/pagination.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pin/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/pin/index.ts new file mode 100644 index 0000000..a1c848b --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pin/index.ts @@ -0,0 +1,4 @@ +import MultiFieldPIN from './multiField.svelte' +import SingleFieldPIN from './singleField.svelte' + +export { MultiFieldPIN, SingleFieldPIN } diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pin/multiField.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/pin/multiField.svelte new file mode 100644 index 0000000..c1fac23 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pin/multiField.svelte @@ -0,0 +1,84 @@ + + + +
    +
    + + + {#each Array.from({ length: 5 }) as _} + + {/each} +
    + + {#if $errors} +
    +
      + {#each $errors as error} +
    • {error}
    • + {/each} +
    +
    + {/if} +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/pin/singleField.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/pin/singleField.svelte new file mode 100644 index 0000000..460bd0d --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/pin/singleField.svelte @@ -0,0 +1,75 @@ + + + +
    + + + {#if $errors} +
    +
      + {#each $errors as error} +
    • {error}
    • + {/each} +
    +
    + {/if} +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/progress/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/progress/index.ts new file mode 100644 index 0000000..fac7a06 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/progress/index.ts @@ -0,0 +1,7 @@ +import Root from './progress.svelte' + +export { + // + Root as Progress, + Root +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/progress/progress.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/progress/progress.svelte new file mode 100644 index 0000000..8e225ea --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/progress/progress.svelte @@ -0,0 +1,23 @@ + + + +
    +
    +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/separator/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..28f2ff2 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from './separator.svelte' + +export { + Root, + // + Root as Separator +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/separator/separator.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..1e388ff --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/sheet/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..493ee95 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,106 @@ +import { Dialog as SheetPrimitive } from 'bits-ui' +import { tv, type VariantProps } from 'tailwind-variants' + +import Content from './sheet-content.svelte' +import Description from './sheet-description.svelte' +import Footer from './sheet-footer.svelte' +import Header from './sheet-header.svelte' +import Overlay from './sheet-overlay.svelte' +import Portal from './sheet-portal.svelte' +import Title from './sheet-title.svelte' + +const Root = SheetPrimitive.Root +const Close = SheetPrimitive.Close +const Trigger = SheetPrimitive.Trigger + +export { + Close, + Content, + Description, + Footer, + Header, + Overlay, + Portal, + Root, + // + Root as Sheet, + Close as SheetClose, + Content as SheetContent, + Description as SheetDescription, + Footer as SheetFooter, + Header as SheetHeader, + Overlay as SheetOverlay, + Portal as SheetPortal, + Title as SheetTitle, + Trigger as SheetTrigger, + Title, + Trigger +} + +export const sheetVariants = tv({ + base: 'fixed z-50 gap-4 bg-background p-6 shadow-lg', + variants: { + side: { + top: 'inset-x-0 top-0 border-b', + bottom: 'inset-x-0 bottom-0 border-t', + left: 'inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', + right: 'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm' + } + }, + defaultVariants: { + side: 'right' + } +}) + +export const sheetTransitions = { + top: { + in: { + y: '-100%', + duration: 500, + opacity: 1 + }, + out: { + y: '-100%', + duration: 300, + opacity: 1 + } + }, + bottom: { + in: { + y: '100%', + duration: 500, + opacity: 1 + }, + out: { + y: '100%', + duration: 300, + opacity: 1 + } + }, + left: { + in: { + x: '-100%', + duration: 500, + opacity: 1 + }, + out: { + x: '-100%', + duration: 300, + opacity: 1 + } + }, + right: { + in: { + x: '100%', + duration: 500, + opacity: 1 + }, + out: { + x: '100%', + duration: 300, + opacity: 1 + } + } +} + +export type Side = VariantProps['side'] diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-content.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..735ef79 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,45 @@ + + + + + + + + + Close + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-description.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..561a3e9 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-footer.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..48b0d9b --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,18 @@ + + +
    + +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-header.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..60d0687 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,15 @@ + + +
    + +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-overlay.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..7659050 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-portal.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-portal.svelte new file mode 100644 index 0000000..fb32d19 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-portal.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-title.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..fdd1440 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/social/Apple.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/social/Apple.svelte new file mode 100644 index 0000000..8dc9bfd --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/social/Apple.svelte @@ -0,0 +1,97 @@ + + + + + + + + + + + {#if error} +
    + {error} +
    + {/if} +
    +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/social/Google.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/social/Google.svelte new file mode 100644 index 0000000..cbe6e06 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/social/Google.svelte @@ -0,0 +1,98 @@ + + + + + + + + + + + {#if error} +
    + {error} +
    + {/if} +
    +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/social/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/social/index.ts new file mode 100644 index 0000000..a2f4a64 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/social/index.ts @@ -0,0 +1,4 @@ +import Apple from './Apple.svelte' +import Google from './Google.svelte' + +export { Apple, Google } diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/table/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..1185be7 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Body from './table-body.svelte' +import Caption from './table-caption.svelte' +import Cell from './table-cell.svelte' +import Footer from './table-footer.svelte' +import Head from './table-head.svelte' +import Header from './table-header.svelte' +import Row from './table-row.svelte' +import Root from './table.svelte' + +export { + Body, + Caption, + Cell, + Footer, + Head, + Header, + Root, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/table/table-body.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..aa71ceb --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/table/table-caption.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..8cfb208 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/table/table-cell.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..aa9b45d --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/table/table-footer.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..dd2741e --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/table/table-head.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..ee10693 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/table/table-header.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..0d9e93e --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,18 @@ + + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/table/table-row.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..7b18184 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/table/table.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..150e527 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/table/table.svelte @@ -0,0 +1,17 @@ + + +
    + + +
    +
    diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/tabs/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..90ca522 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,18 @@ +import { Tabs as TabsPrimitive } from 'bits-ui' +import Content from './tabs-content.svelte' +import List from './tabs-list.svelte' +import Trigger from './tabs-trigger.svelte' + +const Root = TabsPrimitive.Root + +export { + Content, + List, + Root, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, + Trigger +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/tabs/tabs-content.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..e49ded0 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/tabs/tabs-list.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..3a06653 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/tabs/tabs-trigger.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..50c4682 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/tooltip/index.ts b/apps/sveltekit/shadcn/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..d34fdfb --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,15 @@ +import { Tooltip as TooltipPrimitive } from 'bits-ui' +import Content from './tooltip-content.svelte' + +const Root = TooltipPrimitive.Root +const Trigger = TooltipPrimitive.Trigger + +export { + Content, + Root, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Trigger +} diff --git a/apps/sveltekit/shadcn/src/lib/components/ui/tooltip/tooltip-content.svelte b/apps/sveltekit/shadcn/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..2b15ee9 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/apps/sveltekit/shadcn/src/lib/routes.ts b/apps/sveltekit/shadcn/src/lib/routes.ts new file mode 100644 index 0000000..0f3b385 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/routes.ts @@ -0,0 +1,62 @@ +/** + * Please see https://github.com/sveltejs/kit/issues/647#issuecomment-2136031840 + * Credit to https://github.com/david-plugge + */ + +import { resolveRoute } from '$app/paths' +import type RouteMetadata from '../../.svelte-kit/types/route_meta_data.json' +type RouteMetadata = typeof RouteMetadata + +type Prettify = { [K in keyof T]: T[K] } & {} +type ParseParam = T extends `...${infer Name}` ? Name : T + +type ParseParams = + T extends `${infer A}[[${infer Param}]]${infer B}` + ? ParseParams & { [K in ParseParam]?: string } & ParseParams + : T extends `${infer A}[${infer Param}]${infer B}` + ? ParseParams & { [K in ParseParam]: string } & ParseParams + : {} + +type RequiredKeys = keyof { + // eslint-disable-next-line @typescript-eslint/ban-types + [P in keyof T as {} extends Pick ? never : P]: 1 +} + +export type RouteId = keyof RouteMetadata + +export type Routes = { + [K in RouteId]: Prettify> +} + +/** + * @param options routeId, optional params, query and hash + * @example route({ routeId: '/user/:id', params: { id: '1' } }) + * @returns + */ +export const route = ( + options: { + routeId: T + query?: string | Record | URLSearchParams | string[][] + hash?: string + } & (RequiredKeys extends never + ? { params?: Routes[T] } + : { params: Routes[T] }) +) => { + const path = resolveRoute(options.routeId, options.params ?? {}) + const search = options.query && new URLSearchParams(options.query).toString() + return ( + path + + (search ? `?${search}` : '') + + (options.hash ? `#${options.hash}` : '') + ) +} + +export const app = route({ routeId: '/(app)/app' }) +export const login = route({ routeId: '/(other)/login' }) +export const logout = route({ routeId: '/(other)/logout' }) + +export const verifyEmailLink = route({ routeId: '/(other)/verify-email/link' }) +export const verifyEmailCode = route({ routeId: '/(other)/verify-email/code' }) +export const verifyEmailAwaitLink = route({ + routeId: '/(other)/verify-email/awaiting-link' +}) diff --git a/apps/sveltekit/shadcn/src/lib/schemas.ts b/apps/sveltekit/shadcn/src/lib/schemas.ts new file mode 100644 index 0000000..a07e5ca --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/schemas.ts @@ -0,0 +1,28 @@ +import * as v from 'valibot' + +export const registrationFormSchema = v.object({ + email: v.pipe(v.string(), v.email()), + givenName: v.pipe(v.string(), v.minLength(2)), + familyName: v.pipe(v.string(), v.minLength(2)), + acceptTerms: v.boolean(), + token: v.string(), + authType: v.picklist(['passkey', 'email', 'apple', 'google']), + verifyEmail: v.optional(v.picklist(['link', 'code'])) +}) + +export type RegistrationFormSchema = typeof registrationFormSchema + +export const loginFormSchema = v.object({ + email: v.optional(v.pipe(v.string(), v.email())), + token: v.string(), + authType: v.picklist(['passkey', 'email', 'apple', 'google']) +}) + +export type LoginFormSchema = typeof loginFormSchema + +export const verifyEmailSchema = v.object({ + code: v.string(), + token: v.string() +}) + +export type VerifyEmailSchema = typeof verifyEmailSchema diff --git a/apps/sveltekit/shadcn/src/lib/server/auth.ts b/apps/sveltekit/shadcn/src/lib/server/auth.ts new file mode 100644 index 0000000..e5c7ef3 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/server/auth.ts @@ -0,0 +1,53 @@ +import { dev } from '$app/environment' +import { BetterSqlite3Adapter } from '@lucia-auth/adapter-sqlite' +import { sha256 } from 'js-sha256' +import { Lucia } from 'lucia' +import { db } from './db' + +/* + * Note: you can replace js-sha256 with node:crypto + * if running a node backend: + * + * import { createHash } from "node:crypto" + * const hashedEmail = createHash('sha256').update(content).digest('hex') + */ +const buildGravatarUrl = (email: string) => { + const content = email.trim().toLowerCase() + const hashedEmail = sha256(content) + return `https://gravatar.com/avatar/${hashedEmail}?d=mp` +} + +const adapter = new BetterSqlite3Adapter(db, { + user: 'user', + session: 'session' +}) + +export const lucia = new Lucia(adapter, { + sessionCookie: { + attributes: { + secure: !dev + } + }, + getUserAttributes: attributes => { + return { + email: attributes.email, + givenName: attributes.given_name, + familyName: attributes.family_name, + avatar: buildGravatarUrl(attributes.email), + initials: attributes.given_name[0] + attributes.family_name[0] + } + } +}) + +declare module 'lucia' { + interface Register { + Lucia: typeof lucia + DatabaseUserAttributes: DatabaseUserAttributes + } +} + +interface DatabaseUserAttributes { + email: string + given_name: string + family_name: string +} diff --git a/apps/sveltekit/shadcn/src/lib/server/db.ts b/apps/sveltekit/shadcn/src/lib/server/db.ts new file mode 100644 index 0000000..857fa5a --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/server/db.ts @@ -0,0 +1,42 @@ +import sqlite from 'better-sqlite3' +import dedent from 'dedent' + +export const db = sqlite('./sqlite.db') + +const createTablesSql = dedent(` + CREATE TABLE IF NOT EXISTS user ( + id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL, + given_name TEXT NOT NULL, + family_name TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS session ( + id TEXT NOT NULL PRIMARY KEY, + expires_at INTEGER NOT NULL, + user_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) + ); +`) + +export const initLucia = () => { + db.exec(createTablesSql) +} + +export type CreateUser = { + id: string + email: string + givenName: string + familyName: string +} + +export const createUser = async (user: CreateUser) => { + const insert = db.prepare( + 'INSERT INTO user (id, email, given_name, family_name) ' + + 'VALUES (@id, @email, @givenName, @familyName)' + ) + + insert.run(user) + + return user +} diff --git a/apps/sveltekit/shadcn/src/lib/utils.ts b/apps/sveltekit/shadcn/src/lib/utils.ts new file mode 100644 index 0000000..3aa2a15 --- /dev/null +++ b/apps/sveltekit/shadcn/src/lib/utils.ts @@ -0,0 +1,62 @@ +import { clsx, type ClassValue } from 'clsx' +import { cubicOut } from 'svelte/easing' +import type { TransitionConfig } from 'svelte/transition' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +type FlyAndScaleParams = { + y?: number + x?: number + start?: number + duration?: number +} + +export const flyAndScale = ( + node: Element, + params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } +): TransitionConfig => { + const style = getComputedStyle(node) + const transform = style.transform === 'none' ? '' : style.transform + + const scaleConversion = ( + valueA: number, + scaleA: [number, number], + scaleB: [number, number] + ) => { + const [minA, maxA] = scaleA + const [minB, maxB] = scaleB + + const percentage = (valueA - minA) / (maxA - minA) + const valueB = percentage * (maxB - minB) + minB + + return valueB + } + + const styleToString = ( + style: Record + ): string => { + return Object.keys(style).reduce((str, key) => { + if (style[key] === undefined) return str + return str + `${key}:${style[key]};` + }, '') + } + + return { + duration: params.duration ?? 200, + delay: 0, + css: t => { + const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]) + const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]) + const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]) + + return styleToString({ + transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, + opacity: t + }) + }, + easing: cubicOut + } +} diff --git a/apps/sveltekit/shadcn/src/routes/(app)/+layout.server.ts b/apps/sveltekit/shadcn/src/routes/(app)/+layout.server.ts new file mode 100644 index 0000000..8e02239 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(app)/+layout.server.ts @@ -0,0 +1,9 @@ +import { error } from '@sveltejs/kit' +import type { LayoutServerLoad } from './$types' + +export const load = (async ({ locals }) => { + const user = locals.user + if (!user) error(403, 'Access denied') + + return { user } +}) satisfies LayoutServerLoad diff --git a/apps/sveltekit/shadcn/src/routes/(app)/+layout.svelte b/apps/sveltekit/shadcn/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..534f12f --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(app)/+layout.svelte @@ -0,0 +1,30 @@ + + + + + + + + + +
    +
    + +
    +
    + + diff --git a/apps/sveltekit/shadcn/src/routes/(app)/app/+page.svelte b/apps/sveltekit/shadcn/src/routes/(app)/app/+page.svelte new file mode 100644 index 0000000..9019c9e --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(app)/app/+page.svelte @@ -0,0 +1,692 @@ + + +
    + + diff --git a/apps/sveltekit/shadcn/src/routes/(other)/+layout.server.ts b/apps/sveltekit/shadcn/src/routes/(other)/+layout.server.ts new file mode 100644 index 0000000..8efa842 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/+layout.server.ts @@ -0,0 +1,5 @@ +import type { LayoutServerLoad } from './$types' + +export const load = (({ locals }) => { + return { user: locals.user } +}) satisfies LayoutServerLoad diff --git a/apps/sveltekit/shadcn/src/routes/(other)/+layout.svelte b/apps/sveltekit/shadcn/src/routes/(other)/+layout.svelte new file mode 100644 index 0000000..007ebc9 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/+layout.svelte @@ -0,0 +1,38 @@ + + + + + + + + + +
    + +

    + Demo + - Please try registration, email verification & login +

    +
    + +
    + +
    +
    + + diff --git a/apps/sveltekit/shadcn/src/routes/(other)/+page.server.ts b/apps/sveltekit/shadcn/src/routes/(other)/+page.server.ts new file mode 100644 index 0000000..e72dc3c --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/+page.server.ts @@ -0,0 +1,64 @@ +// +page.server.ts +import { registrationFormSchema } from '$lib/schemas' +import { superValidate } from 'sveltekit-superforms' +import { valibot } from 'sveltekit-superforms/adapters' +import type { PageServerLoad } from './$types' + +import { PASSLOCK_API_KEY } from '$env/static/private' +import { + PUBLIC_PASSLOCK_ENDPOINT, + PUBLIC_PASSLOCK_TENANCY_ID +} from '$env/static/public' +import { app, verifyEmailAwaitLink, verifyEmailCode } from '$lib/routes' +import { lucia } from '$lib/server/auth' +import { createUser } from '$lib/server/db' +import { TokenVerifier } from '@passlock/sveltekit' +import { fail, redirect } from '@sveltejs/kit' +import type { Actions } from './$types' + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user) { + redirect(302, app) + } + + return { + form: await superValidate(valibot(registrationFormSchema)) + } +} + +const tokenVerifier = new TokenVerifier({ + tenancyId: PUBLIC_PASSLOCK_TENANCY_ID, + apiKey: PASSLOCK_API_KEY, + endpoint: PUBLIC_PASSLOCK_ENDPOINT +}) + +export const actions = { + default: async ({ request, cookies }) => { + const form = await superValidate(request, valibot(registrationFormSchema)) + + if (!form.valid) { + return fail(400, { form }) + } + + const principal = await tokenVerifier.exchangeToken(form.data.token) + const user = await createUser(principal.user) + const session = await lucia.createSession(user.id, {}) + const sessionCookie = lucia.createSessionCookie(session.id) + + cookies.set(sessionCookie.name, sessionCookie.value, { + path: '/', + ...sessionCookie.attributes + }) + + const authType = form.data.authType + const verifyEmail = form.data.verifyEmail + + if (authType === 'passkey' && verifyEmail === 'code') { + redirect(302, verifyEmailCode) + } else if (authType === 'passkey' && verifyEmail === 'link') { + redirect(302, verifyEmailAwaitLink) + } else { + redirect(302, app) + } + } +} satisfies Actions diff --git a/apps/sveltekit/shadcn/src/routes/(other)/+page.svelte b/apps/sveltekit/shadcn/src/routes/(other)/+page.svelte new file mode 100644 index 0000000..6404ee0 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/+page.svelte @@ -0,0 +1,209 @@ + + +
    +
    + + Login +
    + + + + + +
    +
    +
    +

    Create an account

    +

    + Enter your email below to create your account +

    +
    + +
    +
    +
    + + + + + {#if $superformData.token && $superformData.authType === 'apple'} + + + Sign up with Apple + + {:else if $superformData.token && $superformData.authType === 'google'} + + + Sign up with Google + + {:else} + + + Create passkey + + {/if} +
    +
    + + {#if PUBLIC_APPLE_CLIENT_ID || PUBLIC_GOOGLE_CLIENT_ID} + + {/if} + +
    + {#if PUBLIC_APPLE_CLIENT_ID} + { + await tick() + form.submit() + })} /> + {/if} + + {#if PUBLIC_GOOGLE_CLIENT_ID} + { + await tick() + form.submit() + })} /> + {/if} +
    + + +
    + +

    + By creating an account, you agree to our + + Terms of Service + + and + + Privacy Policy + + . +

    +
    +
    +
    diff --git a/apps/sveltekit/shadcn/src/routes/(other)/login/+page.server.ts b/apps/sveltekit/shadcn/src/routes/(other)/login/+page.server.ts new file mode 100644 index 0000000..5eca3ad --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/login/+page.server.ts @@ -0,0 +1,53 @@ +import { PASSLOCK_API_KEY } from '$env/static/private' +import { + PUBLIC_PASSLOCK_ENDPOINT, + PUBLIC_PASSLOCK_TENANCY_ID +} from '$env/static/public' +import { app } from '$lib/routes' +import { loginFormSchema } from '$lib/schemas' +import { lucia } from '$lib/server/auth' +import { TokenVerifier } from '@passlock/sveltekit' +import { fail, redirect } from '@sveltejs/kit' +import { superValidate } from 'sveltekit-superforms' +import { valibot } from 'sveltekit-superforms/adapters' +import type { Actions, PageServerLoad } from './$types' + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user) { + redirect(302, app) + } + + return { + form: await superValidate(valibot(loginFormSchema)) + } +} + +const tokenVerifier = new TokenVerifier({ + tenancyId: PUBLIC_PASSLOCK_TENANCY_ID, + apiKey: PASSLOCK_API_KEY, + endpoint: PUBLIC_PASSLOCK_ENDPOINT +}) + +export const actions = { + default: async ({ request, cookies }) => { + const form = await superValidate(request, valibot(loginFormSchema)) + + if (!form.valid) { + return fail(400, { form }) + } + + // Verify the Passlock token is genuine + const principal = await tokenVerifier.exchangeToken(form.data.token) + + const session = await lucia.createSession(principal.user.id, {}) + + const sessionCookie = lucia.createSessionCookie(session.id) + + cookies.set(sessionCookie.name, sessionCookie.value, { + path: '/', + ...sessionCookie.attributes + }) + + redirect(302, app) + } +} satisfies Actions diff --git a/apps/sveltekit/shadcn/src/routes/(other)/login/+page.svelte b/apps/sveltekit/shadcn/src/routes/(other)/login/+page.svelte new file mode 100644 index 0000000..0945d7c --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/login/+page.svelte @@ -0,0 +1,146 @@ + + +
    +
    + +
    + + + +
    +
    +
    +

    Login

    +

    + Enter your email below to login to your account +

    +
    + +
    +
    +
    + + + + + Login with passkey + +
    +
    + + {#if PUBLIC_APPLE_CLIENT_ID || PUBLIC_GOOGLE_CLIENT_ID} + + {/if} + +
    + {#if PUBLIC_APPLE_CLIENT_ID} + { + form.submit() + })} /> + {/if} + + {#if PUBLIC_GOOGLE_CLIENT_ID} + { + form.submit() + })} /> + {/if} +
    + + + +
    + Don't have an account? + Sign up +
    +
    +
    +
    + + diff --git a/apps/sveltekit/shadcn/src/routes/(other)/logout/+page.server.ts b/apps/sveltekit/shadcn/src/routes/(other)/logout/+page.server.ts new file mode 100644 index 0000000..de9cb09 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/logout/+page.server.ts @@ -0,0 +1,16 @@ +import { login } from '$lib/routes' +import { lucia } from '$lib/server/auth' +import { redirect } from '@sveltejs/kit' +import type { Actions } from './$types' + +export const actions = { + default: async ({ locals }) => { + const session = locals.session + + if (session) { + lucia.invalidateSession(session.id) + } + + redirect(302, login) + } +} satisfies Actions diff --git a/apps/sveltekit/shadcn/src/routes/(other)/verify-email/awaiting-link/+page.svelte b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/awaiting-link/+page.svelte new file mode 100644 index 0000000..329c038 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/awaiting-link/+page.svelte @@ -0,0 +1,57 @@ + + +
    +
    + +
    + + + + + + Check your emails + + We've sent you a link.
    + Please check your emails (including junk) +
    +
    + +
    + Still waiting? + Resend code +
    +
    +
    +
    diff --git a/apps/sveltekit/shadcn/src/routes/(other)/verify-email/code/+page.server.ts b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/code/+page.server.ts new file mode 100644 index 0000000..5decd67 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/code/+page.server.ts @@ -0,0 +1,24 @@ +import { app } from '$lib/routes' +import { verifyEmailSchema } from '$lib/schemas' +import { redirect } from '@sveltejs/kit' +import { fail, superValidate } from 'sveltekit-superforms' +import { valibot } from 'sveltekit-superforms/adapters' +import type { Actions, PageServerLoad } from './$types' + +export const load = (async () => { + return { + form: await superValidate(valibot(verifyEmailSchema)) + } +}) satisfies PageServerLoad + +export const actions = { + default: async ({ request }) => { + const form = await superValidate(request, valibot(verifyEmailSchema)) + + if (!form.valid) { + return fail(400, { form }) + } + + redirect(302, app) + } +} satisfies Actions diff --git a/apps/sveltekit/shadcn/src/routes/(other)/verify-email/code/+page.svelte b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/code/+page.svelte new file mode 100644 index 0000000..8660d55 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/code/+page.svelte @@ -0,0 +1,92 @@ + + +
    +
    + +
    + + + +
    + + + Enter your code + + We've sent you a verification code.
    + Please check your emails (including junk) +
    +
    + + form.submit()} /> + + + + + Verify email + +
    + {#if resendDisabled} + Email sent + {:else} + Still waiting? + + {/if} +
    +
    +
    +
    +
    diff --git a/apps/sveltekit/shadcn/src/routes/(other)/verify-email/link/+page.server.ts b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/link/+page.server.ts new file mode 100644 index 0000000..d028d53 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/link/+page.server.ts @@ -0,0 +1,27 @@ +import { app } from '$lib/routes' +import { verifyEmailSchema } from '$lib/schemas' +import { error, redirect } from '@sveltejs/kit' +import { fail, superValidate } from 'sveltekit-superforms' +import { valibot } from 'sveltekit-superforms/adapters' +import type { Actions, PageServerLoad } from './$types' + +export const load = (async ({ url }) => { + const code = url.searchParams.get('code') + if (!code) error(400, 'Expected ?code search parameter') + + return { + form: await superValidate({ code }, valibot(verifyEmailSchema)) + } +}) satisfies PageServerLoad + +export const actions = { + default: async ({ request, cookies }) => { + const form = await superValidate(request, valibot(verifyEmailSchema)) + + if (!form.valid) { + return fail(400, { form }) + } + + redirect(302, app) + } +} satisfies Actions diff --git a/apps/sveltekit/shadcn/src/routes/(other)/verify-email/link/+page.svelte b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/link/+page.svelte new file mode 100644 index 0000000..7877a27 --- /dev/null +++ b/apps/sveltekit/shadcn/src/routes/(other)/verify-email/link/+page.svelte @@ -0,0 +1,93 @@ + + +
    +
    + +
    + + + +
    + + + Verify your email + + Please click below to verify your email + + + + + + Verify email + + +
    + {#if resendDisabled} + Email sent + {:else} + Still waiting? + + {/if} +
    +
    +
    +
    +
    diff --git a/apps/sveltekit/shadcn/static/android-chrome-192x192.png b/apps/sveltekit/shadcn/static/android-chrome-192x192.png new file mode 100644 index 0000000..cf9d3fd Binary files /dev/null and b/apps/sveltekit/shadcn/static/android-chrome-192x192.png differ diff --git a/apps/sveltekit/shadcn/static/android-chrome-512x512.png b/apps/sveltekit/shadcn/static/android-chrome-512x512.png new file mode 100644 index 0000000..d1d78e5 Binary files /dev/null and b/apps/sveltekit/shadcn/static/android-chrome-512x512.png differ diff --git a/apps/sveltekit/shadcn/static/apple-touch-icon.png b/apps/sveltekit/shadcn/static/apple-touch-icon.png new file mode 100644 index 0000000..feb4875 Binary files /dev/null and b/apps/sveltekit/shadcn/static/apple-touch-icon.png differ diff --git a/apps/sveltekit/shadcn/static/browserconfig.xml b/apps/sveltekit/shadcn/static/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/apps/sveltekit/shadcn/static/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/apps/sveltekit/shadcn/static/favicon-16x16.png b/apps/sveltekit/shadcn/static/favicon-16x16.png new file mode 100644 index 0000000..e49b0bf Binary files /dev/null and b/apps/sveltekit/shadcn/static/favicon-16x16.png differ diff --git a/apps/sveltekit/shadcn/static/favicon-32x32.png b/apps/sveltekit/shadcn/static/favicon-32x32.png new file mode 100644 index 0000000..49abcc0 Binary files /dev/null and b/apps/sveltekit/shadcn/static/favicon-32x32.png differ diff --git a/apps/sveltekit/shadcn/static/favicon.ico b/apps/sveltekit/shadcn/static/favicon.ico new file mode 100644 index 0000000..1724c15 Binary files /dev/null and b/apps/sveltekit/shadcn/static/favicon.ico differ diff --git a/apps/sveltekit/shadcn/static/images/bg-hero.jpg b/apps/sveltekit/shadcn/static/images/bg-hero.jpg new file mode 100644 index 0000000..1b8c8e6 Binary files /dev/null and b/apps/sveltekit/shadcn/static/images/bg-hero.jpg differ diff --git a/apps/sveltekit/shadcn/static/mstile-144x144.png b/apps/sveltekit/shadcn/static/mstile-144x144.png new file mode 100644 index 0000000..8184062 Binary files /dev/null and b/apps/sveltekit/shadcn/static/mstile-144x144.png differ diff --git a/apps/sveltekit/shadcn/static/mstile-150x150.png b/apps/sveltekit/shadcn/static/mstile-150x150.png new file mode 100644 index 0000000..b9a6e98 Binary files /dev/null and b/apps/sveltekit/shadcn/static/mstile-150x150.png differ diff --git a/apps/sveltekit/shadcn/static/mstile-310x150.png b/apps/sveltekit/shadcn/static/mstile-310x150.png new file mode 100644 index 0000000..c3a6a63 Binary files /dev/null and b/apps/sveltekit/shadcn/static/mstile-310x150.png differ diff --git a/apps/sveltekit/shadcn/static/mstile-310x310.png b/apps/sveltekit/shadcn/static/mstile-310x310.png new file mode 100644 index 0000000..f6cd683 Binary files /dev/null and b/apps/sveltekit/shadcn/static/mstile-310x310.png differ diff --git a/apps/sveltekit/shadcn/static/mstile-70x70.png b/apps/sveltekit/shadcn/static/mstile-70x70.png new file mode 100644 index 0000000..1b2207a Binary files /dev/null and b/apps/sveltekit/shadcn/static/mstile-70x70.png differ diff --git a/apps/sveltekit/shadcn/static/repo-banner.dark.svg b/apps/sveltekit/shadcn/static/repo-banner.dark.svg new file mode 100644 index 0000000..9261dc1 --- /dev/null +++ b/apps/sveltekit/shadcn/static/repo-banner.dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/sveltekit/shadcn/static/repo-banner.svg b/apps/sveltekit/shadcn/static/repo-banner.svg new file mode 100644 index 0000000..82ec35e --- /dev/null +++ b/apps/sveltekit/shadcn/static/repo-banner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/sveltekit/shadcn/static/robots.txt b/apps/sveltekit/shadcn/static/robots.txt new file mode 100644 index 0000000..a7ade78 --- /dev/null +++ b/apps/sveltekit/shadcn/static/robots.txt @@ -0,0 +1,4 @@ +# https://www.robotstxt.org/robotstxt.html + +User-agent: * +Disallow: / \ No newline at end of file diff --git a/apps/sveltekit/shadcn/static/safari-pinned-tab.svg b/apps/sveltekit/shadcn/static/safari-pinned-tab.svg new file mode 100644 index 0000000..cf5e011 --- /dev/null +++ b/apps/sveltekit/shadcn/static/safari-pinned-tab.svg @@ -0,0 +1,43 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + + diff --git a/apps/sveltekit/shadcn/static/site.webmanifest b/apps/sveltekit/shadcn/static/site.webmanifest new file mode 100644 index 0000000..f3c9ec6 --- /dev/null +++ b/apps/sveltekit/shadcn/static/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Passlock Demo", + "short_name": "Demo", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/sveltekit/shadcn/svelte.config.js b/apps/sveltekit/shadcn/svelte.config.js new file mode 100644 index 0000000..8094346 --- /dev/null +++ b/apps/sveltekit/shadcn/svelte.config.js @@ -0,0 +1,16 @@ +import { preprocessMeltUI, sequence } from '@melt-ui/pp' +import adapter from '@sveltejs/adapter-auto' +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' +/** @type {import('@sveltejs/kit').Config}*/ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: sequence([vitePreprocess({}), preprocessMeltUI()]), + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter() + } +} +export default config diff --git a/apps/sveltekit/shadcn/tailwind.config.js b/apps/sveltekit/shadcn/tailwind.config.js new file mode 100644 index 0000000..5b74486 --- /dev/null +++ b/apps/sveltekit/shadcn/tailwind.config.js @@ -0,0 +1,67 @@ +import { fontFamily } from 'tailwindcss/defaultTheme' + +/** @type {import('tailwindcss').Config} */ +const config = { + darkMode: ['class'], + content: ['./src/**/*.{html,js,svelte,ts}'], + safelist: ['dark'], + theme: { + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'hsl(var(--border) / )', + input: 'hsl(var(--input) / )', + ring: 'hsl(var(--ring) / )', + background: 'hsl(var(--background) / )', + foreground: 'hsl(var(--foreground) / )', + primary: { + DEFAULT: 'hsl(var(--primary) / )', + foreground: 'hsl(var(--primary-foreground) / )' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary) / )', + foreground: 'hsl(var(--secondary-foreground) / )' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive) / )', + foreground: 'hsl(var(--destructive-foreground) / )' + }, + muted: { + DEFAULT: 'hsl(var(--muted) / )', + foreground: 'hsl(var(--muted-foreground) / )' + }, + accent: { + DEFAULT: 'hsl(var(--accent) / )', + foreground: 'hsl(var(--accent-foreground) / )' + }, + popover: { + DEFAULT: 'hsl(var(--popover) / )', + foreground: 'hsl(var(--popover-foreground) / )' + }, + card: { + DEFAULT: 'hsl(var(--card) / )', + foreground: 'hsl(var(--card-foreground) / )' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + fontFamily: { + sans: [...fontFamily.sans] + }, + animation: { + 'spin-slow': 'spin 2s linear infinite' + } + } + } +} + +export default config diff --git a/apps/sveltekit/shadcn/tsconfig.json b/apps/sveltekit/shadcn/tsconfig.json new file mode 100644 index 0000000..593dc19 --- /dev/null +++ b/apps/sveltekit/shadcn/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias + // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/apps/sveltekit/shadcn/vite.config.ts b/apps/sveltekit/shadcn/vite.config.ts new file mode 100644 index 0000000..17418c5 --- /dev/null +++ b/apps/sveltekit/shadcn/vite.config.ts @@ -0,0 +1,10 @@ +import { sveltekit } from '@sveltejs/kit/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 5174, + strictPort: false + } +}) diff --git a/docs/repo-banner.dark.svg b/docs/repo-banner.dark.svg deleted file mode 100644 index e93f639..0000000 --- a/docs/repo-banner.dark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/repo-banner.svg b/docs/repo-banner.svg deleted file mode 100644 index 1c96743..0000000 --- a/docs/repo-banner.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/package.json b/package.json index a0e3c02..56913d1 100644 --- a/package.json +++ b/package.json @@ -1,53 +1,34 @@ { - "name": "sveltekit", - "version": "0.0.1", + "name": "public", + "description": "Monorepo containing the JS/TS client libraries", + "version": "0.0.0", "private": true, + "type": "module", "scripts": { - "dev": "vite dev --host --force", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:errors": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold error", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "format": "prettier --write .", - "ncu": "ncu --peer -x @passlock/client -x better-sqlite3 -x postcss-load-config", - "ncu:save": "ncu --peer -x @passlock/client -x better-sqlite3 -x postcss-load-config -u", - "pnpm:upgrade": "corepack use pnpm@latest" + "typecheck": "pnpm -r run typecheck", + "lint": "pnpm -r run lint", + "lint:fix": "pnpm -r run lint:fix", + "format": "pnpm run -r format", + "ncu": "ncu --peer -x @effect/* -x @passlock/* -x effect -x better-sqlite3 -x postcss-load-config && pnpm -r run ncu", + "ncu:save": "ncu --peer -x @effect/* -x @passlock/* -x effect -x better-sqlite3 -x postcss-load-config -u && pnpm -r run ncu:save", + "upgrade:pnpm": "corepack use pnpm@latest" }, "devDependencies": { - "@lucia-auth/adapter-sqlite": "^3.0.2", - "@melt-ui/pp": "^0.3.2", - "@melt-ui/svelte": "^0.83.0", - "@passlock/sveltekit": "0.9.20", - "@sveltejs/adapter-auto": "^3.2.2", - "@sveltejs/kit": "^2.5.18", - "@sveltejs/vite-plugin-svelte": "^3.1.1", - "@tailwindcss/aspect-ratio": "^0.4.2", - "@tailwindcss/forms": "^0.5.7", - "@types/apple-signin-api": "^1.5.3", - "@types/better-sqlite3": "^7.6.11", - "@types/google-one-tap": "^1.2.6", - "autoprefixer": "^10.4.19", - "better-sqlite3": "~9.6.0", - "dedent": "^1.5.3", - "js-sha256": "^0.11.0", - "lucia": "^3.2.0", - "lucide-svelte": "^0.399.0", - "mode-watcher": "^0.3.1", - "node-check-updates": "^0.1.9", - "postcss": "^8.4.39", - "postcss-load-config": "^5.1.0", - "prettier": "^3.3.2", - "prettier-plugin-svelte": "^3.2.5", - "svelte": "^4.2.18", - "svelte-check": "^3.8.4", - "sveltekit-superforms": "^2.16.0", - "tailwindcss": "^3.4.4", - "tslib": "^2.6.3", - "typescript": "^5.5.3", - "valibot": "^0.36.0", - "vite": "^5.3.3" + "@tsconfig/node20": "^20.1.4", + "typescript": "^5.5.3" + }, + "pnpm": { + "overrides": { + "@passlock/sveltekit": "workspace:*", + "@passlock/client": "workspace:*", + "@passlock/shared": "workspace:*", + "effect": "^3.4.8", + "@effect/schema": "^0.68.18", + "@aws-sdk/lib-dynamodb": "3.395.0", + "@aws-sdk/client-dynamodb": "3.395.0", + "@aws-sdk/client-sts": "3.606.0", + "@aws-sdk/client-sso-oidc": "3.606.0" + } }, - "type": "module", "packageManager": "pnpm@9.5.0+sha256.dbdf5961c32909fb030595a9daa1dae720162e658609a8f92f2fa99835510ca5" } diff --git a/packages/client/.eslintignore b/packages/client/.eslintignore new file mode 100644 index 0000000..4e4b960 --- /dev/null +++ b/packages/client/.eslintignore @@ -0,0 +1,2 @@ +/* +!/src \ No newline at end of file diff --git a/packages/client/.eslintrc.cjs b/packages/client/.eslintrc.cjs new file mode 100644 index 0000000..701ab02 --- /dev/null +++ b/packages/client/.eslintrc.cjs @@ -0,0 +1,88 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true + }, + extends: [ + 'eslint:recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/strict-type-checked', + 'prettier' + ], + overrides: [], + parser: "@typescript-eslint/parser", + parserOptions: { + project: './tsconfig.json' + }, + plugins: [ + "@typescript-eslint", + "import" + ], + root: true, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "ignoreRestSiblings": true, + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/consistent-type-definitions": [ + "warn", + "type" + ], + "@typescript-eslint/consistent-type-imports": [ + "error" + ], + "sort-imports": [ + "warn", + { + ignoreCase: false, + ignoreDeclarationSort: true, // don"t want to sort import lines, use eslint-plugin-import instead + ignoreMemberSort: false, + memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], + allowSeparatedGroups: true, + }, + ], + + "import/no-unresolved": "error", + "import/newline-after-import": [ + "warn", + { + "count": 1 + } + ], + 'import/order': [ + 'warn', + { + groups: [ + 'builtin', // Built-in imports (come from NodeJS native) go first + 'external', // <- External imports + 'internal', // <- Absolute imports + ['sibling', 'parent'], // <- Relative imports, the sibling and parent types they can be mingled together + 'index', // <- index imports + 'unknown', // <- unknown + ], + 'newlines-between': 'ignore', + alphabetize: { + /* sort in ascending order. Options: ["ignore", "asc", "desc"] */ + order: 'asc', + /* ignore case. Options: [true, false] */ + caseInsensitive: true, + }, + }, + ] + }, + settings: { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"] + }, + "import/resolver": { + "typescript": true + } + } +} \ No newline at end of file diff --git a/packages/client/.gitignore b/packages/client/.gitignore new file mode 100644 index 0000000..b736c88 --- /dev/null +++ b/packages/client/.gitignore @@ -0,0 +1,34 @@ +# dependencies +node_modules + +# production +build +dist +dist-ssr +*.local + +# logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# typescript +tsconfig.tsbuildinfo + +# editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# coverage +coverage diff --git a/packages/client/.prettierrc.json b/packages/client/.prettierrc.json new file mode 100644 index 0000000..5f7b9ac --- /dev/null +++ b/packages/client/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": false, + "singleQuote": true, + "arrowParens": "avoid", + "printWidth": 100, + "quoteProps": "consistent", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "ignore", + "bracketSameLine": true, + "useTabs": false +} diff --git a/packages/client/LICENSE b/packages/client/LICENSE new file mode 100644 index 0000000..aca6dbd --- /dev/null +++ b/packages/client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Passlock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..e1ae01b --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,85 @@ +{ + "name": "@passlock/client", + "version": "0.9.19", + "description": "Easy WebAuthn/FIDO Passkey authentication and social login for web apps. This library works with pretty much any frontend/backend stack including React/Next.js, Vue etc.", + "keywords": [ + "passkey", + "passkeys", + "webauthn", + "google one tap", + "sign in with google", + "sign in with apple", + "react", + "next", + "vue", + "nuxt" + ], + "author": { + "name": "Toby Hobson", + "email": "toby@passlock.dev" + }, + "license": "MIT", + "homepage": "https://passlock.dev", + "repository": "github.com/passlock-dev/passkeys", + "bugs": { + "url": "https://github.com/passlock-dev/passkeys/issues", + "email": "team@passlock.dev" + }, + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./effect": { + "import": "./dist/effect.js", + "types": "./dist/effect.d.ts" + } + }, + "files": [ + "src", + "dist" + ], + "scripts": { + "clean": "tsc --build --clean", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest dev", + "test:ui": "vitest --coverage.enabled=true --ui", + "test:coverage": "vitest run --coverage", + "build": "tsc --build", + "build:clean": "pnpm run clean && pnpm run build", + "build:watch": "tsc --build --watch", + "format": "prettier --write \"src/**/*.+(js|ts|json)\"", + "lint": "eslint --ext .ts src", + "lint:fix": "pnpm run lint --fix", + "prepublishOnly": "pnpm run clean && pnpm run build", + "ncu": "ncu --peer -x @effect/* -x effect", + "ncu:save": "ncu --peer -x @effect/* -x effect -u" + }, + "dependencies": { + "@passlock/shared": "workspace:*", + "@github/webauthn-json": "^2.1.1", + "effect": "^3.4.8" + }, + "devDependencies": { + "@tsconfig/node18": "^18.2.4", + "@types/node": "^20.14.10", + "@typescript-eslint/eslint-plugin": "^7.15.0", + "@typescript-eslint/parser": "^7.15.0", + "@vitest/coverage-v8": "^2.0.3", + "@vitest/ui": "^2.0.3", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "jsdom": "^24.1.0", + "prettier": "^3.3.2", + "rimraf": "^5.0.8", + "tslib": "^2.6.3", + "typescript": "^5.5.3", + "vite": "^5.3.3", + "vitest": "^2.0.3", + "vitest-mock-extended": "^1.3.1" + } +} diff --git a/packages/client/public/passlock.svg b/packages/client/public/passlock.svg new file mode 100644 index 0000000..9b0baf9 --- /dev/null +++ b/packages/client/public/passlock.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/client/public/vite.svg b/packages/client/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/packages/client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/client/src/authentication/authenticate.fixture.ts b/packages/client/src/authentication/authenticate.fixture.ts new file mode 100644 index 0000000..ace968b --- /dev/null +++ b/packages/client/src/authentication/authenticate.fixture.ts @@ -0,0 +1,70 @@ +import { + AuthenticationClient, + OptionsRes, + VerificationReq, + VerificationRes, +} from '@passlock/shared/dist/rpc/authentication.js' +import { IsExistingUserRes, VerifyEmailRes } from '@passlock/shared/dist/rpc/user.js' +import type { AuthenticationCredential } from '@passlock/shared/dist/schema/schema.js' +import { Effect as E, Layer as L } from 'effect' +import * as Fixtures from '../test/fixtures.js' +import { GetCredential, type AuthenticationRequest } from './authenticate.js' + +export const session = 'session' +export const token = 'token' +export const code = 'code' +export const authType = 'passkey' +export const expireAt = Date.now() + 10000 + +export const request: AuthenticationRequest = { + userVerification: 'preferred', +} + +export const rpcOptionsRes = new OptionsRes({ + session, + publicKey: { + rpId: 'passlock.dev', + challenge: 'FKZSl_saKu5OXjLLwoq8eK3wlD8XgpGiS10SszW5RiE', + timeout: 60000, + userVerification: 'preferred', + }, +}) + +export const credential: AuthenticationCredential = { + id: '1', + type: 'public-key', + rawId: 'id', + response: { + clientDataJSON: '', + authenticatorData: '', + signature: '', + userHandle: null, + }, + clientExtensionResults: {}, + authenticatorAttachment: null, +} + +export const rpcVerificationReq = new VerificationReq({ session, credential }) + +export const rpcVerificationRes = new VerificationRes({ principal: Fixtures.principal }) + +export const rpcIsExistingUserRes = new IsExistingUserRes({ existingUser: true }) + +export const rpcVerifyEmailRes = new VerifyEmailRes({ principal: Fixtures.principal }) + +export const getCredentialTest = L.succeed( + GetCredential, + GetCredential.of(() => E.succeed(credential)), +) + +export const rpcClientTest = L.succeed( + AuthenticationClient, + AuthenticationClient.of({ + getAuthenticationOptions: () => E.succeed(rpcOptionsRes), + verifyAuthenticationCredential: () => E.succeed(rpcVerificationRes), + }) +) + +export const principal = Fixtures.principal +export const capabilitiesTest = Fixtures.capabilitiesTest +export const storageServiceTest = Fixtures.storageServiceTest diff --git a/packages/client/src/authentication/authenticate.test.ts b/packages/client/src/authentication/authenticate.test.ts new file mode 100644 index 0000000..33939d3 --- /dev/null +++ b/packages/client/src/authentication/authenticate.test.ts @@ -0,0 +1,206 @@ +import { AuthenticationClient } from '@passlock/shared/dist/rpc/authentication.js' +import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect' +import { describe, expect, test, vi } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { StorageService } from '../storage/storage.js' +import * as Fixture from './authenticate.fixture.js' +import { AuthenticateServiceLive, AuthenticationService, GetCredential } from './authenticate.js' + +describe('authenticate should', () => { + test('return a valid principal', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(AuthenticationService) + const result = yield* _(service.authenticatePasskey({ userVerification: 'preferred' })) + + expect(result).toEqual(Fixture.principal) + }) + + const service = pipe( + AuthenticateServiceLive, + L.provide(Fixture.getCredentialTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.rpcClientTest), + ) + + const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('pass the authentication request to the backend', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(AuthenticationService) + yield* _(service.authenticatePasskey({ userVerification: 'preferred' })) + + const rpcClient = yield* _(AuthenticationClient) + expect(rpcClient.getAuthenticationOptions).toHaveBeenCalledOnce() + expect(rpcClient.verifyAuthenticationCredential).toHaveBeenCalledOnce() + }) + + const rpcClientTest = L.effect( + AuthenticationClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.getAuthenticationOptions.mockReturnValue(E.succeed(Fixture.rpcOptionsRes)) + rpcMock.verifyAuthenticationCredential.mockReturnValue(E.succeed(Fixture.rpcVerificationRes)) + + return rpcMock + }), + ) + + const service = pipe( + AuthenticateServiceLive, + L.provide(Fixture.getCredentialTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.storageServiceTest), + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('send the credential to the backend', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(AuthenticationService) + yield* _(service.authenticatePasskey({ userVerification: 'preferred' })) + + const rpcClient = yield* _(AuthenticationClient) + expect(rpcClient.getAuthenticationOptions).toHaveBeenCalledOnce() + expect(rpcClient.verifyAuthenticationCredential).toHaveBeenCalledWith(Fixture.rpcVerificationReq) + }) + + const rpcClientTest = L.effect( + AuthenticationClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.getAuthenticationOptions.mockReturnValue(E.succeed(Fixture.rpcOptionsRes)) + rpcMock.verifyAuthenticationCredential.mockReturnValue(E.succeed(Fixture.rpcVerificationRes)) + + return rpcMock + }), + ) + + const service = pipe( + AuthenticateServiceLive, + L.provide(Fixture.getCredentialTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.storageServiceTest), + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('store the credential in local storage', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(AuthenticationService) + yield* _(service.authenticatePasskey({ userVerification: 'preferred' })) + + const storageService = yield* _(StorageService) + expect(storageService.storeToken).toHaveBeenCalledWith(Fixture.principal) + }) + + const storageServiceTest = L.effect( + StorageService, + E.sync(() => { + const storageMock = mock() + + storageMock.storeToken.mockReturnValue(E.void) + storageMock.clearExpiredToken.mockReturnValue(E.void) + + return storageMock + }), + ) + + const service = pipe( + AuthenticateServiceLive, + L.provide(Fixture.getCredentialTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.rpcClientTest), + L.provide(storageServiceTest), + ) + + const layers = Layer.merge(service, storageServiceTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('schedule deletion of the local token', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(AuthenticationService) + yield* _(service.authenticatePasskey({ userVerification: 'preferred' })) + + const storageService = yield* _(StorageService) + expect(storageService.clearExpiredToken).toHaveBeenCalledWith('passkey') + }) + + const storageServiceTest = L.effect( + StorageService, + E.sync(() => { + const storageMock = mock() + + storageMock.storeToken.mockReturnValue(E.void) + storageMock.clearExpiredToken.mockReturnValue(E.void) + + return storageMock + }), + ) + + const service = pipe( + AuthenticateServiceLive, + L.provide(Fixture.getCredentialTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.rpcClientTest), + L.provide(storageServiceTest), + ) + + const layers = Layer.merge(service, storageServiceTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test("return an error if the browser can't create a credential", async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(AuthenticationService) + yield* _(service.authenticatePasskey({ userVerification: 'preferred' })) + + const getCredential = yield* _(GetCredential) + expect(getCredential).toHaveBeenCalledOnce() + }) + + const getCredentialTest = L.effect( + GetCredential, + E.sync(() => { + const getCredentialMock = vi.fn() + + getCredentialMock.mockReturnValue(E.succeed(Fixture.credential)) + + return getCredentialMock + }), + ) + + const service = pipe( + AuthenticateServiceLive, + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.rpcClientTest), + L.provide(getCredentialTest), + ) + + const layers = Layer.merge(service, getCredentialTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) +}) diff --git a/packages/client/src/authentication/authenticate.ts b/packages/client/src/authentication/authenticate.ts new file mode 100644 index 0000000..d44dc2f --- /dev/null +++ b/packages/client/src/authentication/authenticate.ts @@ -0,0 +1,145 @@ +/** + * Passkey authentication effects + */ +import { + parseRequestOptionsFromJSON, + type CredentialRequestOptionsJSON, +} from '@github/webauthn-json/browser-ponyfill' +import { + InternalBrowserError, + type NotSupported, +} from '@passlock/shared/dist/error/error.js' +import type { OptionsErrors, VerificationErrors } from '@passlock/shared/dist/rpc/authentication.js' +import { AuthenticationClient, OptionsReq, VerificationReq } from '@passlock/shared/dist/rpc/authentication.js' +import type { + AuthenticationCredential, + Principal, + UserVerification, +} from '@passlock/shared/dist/schema/schema.js' +import { Context, Effect as E, Layer, flow, pipe } from 'effect' +import { Capabilities } from '../capabilities/capabilities.js' +import { StorageService } from '../storage/storage.js' + +/* Requests */ + +export type AuthenticationRequest = { + email?: string, + userVerification?: UserVerification +} + +/* Errors */ + +export type AuthenticationErrors = NotSupported | OptionsErrors | VerificationErrors + +/* Dependencies */ + +export type GetCredential = ( + request: CredentialRequestOptions, +) => E.Effect + +export const GetCredential = Context.GenericTag('@services/Get') + +/* Service */ + +export type AuthenticationService = { + authenticatePasskey: (request: AuthenticationRequest) => E.Effect +} + +export const AuthenticationService = Context.GenericTag( + '@services/AuthenticationService', +) + +/* Utilities */ + +const fetchOptions = (request: OptionsReq) => { + return E.gen(function* (_) { + yield* _(E.logDebug('Making request')) + + const rpcClient = yield* _(AuthenticationClient) + const { publicKey, session } = yield* _(rpcClient.getAuthenticationOptions(request)) + + yield* _(E.logDebug('Converting Passlock options to CredentialRequestOptions')) + const options = yield* _(toRequestOptions({ publicKey })) + + return { options, session } + }) +} + +const toRequestOptions = (request: CredentialRequestOptionsJSON) => { + return pipe( + E.try(() => parseRequestOptionsFromJSON(request)), + E.mapError( + error => + new InternalBrowserError({ + message: 'Browser was unable to create credential request options', + detail: String(error.error), + }), + ), + ) +} + +const verifyCredential = (request: VerificationReq) => { + return E.gen(function* (_) { + yield* _(E.logDebug('Making request')) + + const rpcClient = yield* _(AuthenticationClient) + const { principal } = yield* _(rpcClient.verifyAuthenticationCredential(request)) + + return principal + }) +} + +/* Effects */ + +type Dependencies = GetCredential | Capabilities | StorageService | AuthenticationClient + +export const authenticatePasskey = ( + request: AuthenticationRequest, +): E.Effect => { + const effect = E.gen(function* (_) { + yield* _(E.logInfo('Checking if browser supports Passkeys')) + const capabilities = yield* _(Capabilities) + yield* _(capabilities.passkeySupport) + + yield* _(E.logInfo('Fetching authentication options from Passlock')) + const { options, session } = yield* _(fetchOptions(new OptionsReq(request))) + + yield* _(E.logInfo('Looking up credential')) + const get = yield* _(GetCredential) + const credential = yield* _(get(options)) + + yield* _(E.logInfo('Verifying credential with Passlock')) + const principal = yield* _(verifyCredential(new VerificationReq({ credential, session }))) + + const storageService = yield* _(StorageService) + yield* _(storageService.storeToken(principal)) + yield* _(E.logDebug('Stored token in local storage')) + + yield* _(E.logDebug('Defering local token deletion')) + const delayedClearTokenE = pipe( + storageService.clearExpiredToken('passkey'), + E.delay('6 minutes'), + E.fork, + ) + yield* _(delayedClearTokenE) + + return principal + }) + + return E.catchTag(effect, 'InternalBrowserError', e => E.die(e)) +} + +/* Live */ + +/* v8 ignore start */ +export const AuthenticateServiceLive = Layer.effect( + AuthenticationService, + E.gen(function* (_) { + const context = yield* _(E.context()) + + return AuthenticationService.of({ + authenticatePasskey: flow(authenticatePasskey, E.provide(context)), + }) + }), +) +/* v8 ignore stop */ diff --git a/packages/client/src/capabilities/capabilities.ts b/packages/client/src/capabilities/capabilities.ts new file mode 100644 index 0000000..60ac6a6 --- /dev/null +++ b/packages/client/src/capabilities/capabilities.ts @@ -0,0 +1,81 @@ +/** + * Test if the browser supports passkeys, conditional UI etc + */ +import { NotSupported } from '@passlock/shared/dist/error/error.js' +import { Context, Effect as E, Layer, identity, pipe } from 'effect' + +/* Service */ + +export type Capabilities = { + passkeySupport: E.Effect + isPasskeySupport: E.Effect + autofillSupport: E.Effect + isAutofillSupport: E.Effect +} + +export const Capabilities = Context.GenericTag('@services/Capabilities') + +/* Effects */ + +const hasWebAuthn = E.suspend(() => + typeof window.PublicKeyCredential === 'function' + ? E.void + : new NotSupported({ message: 'WebAuthn API is not supported on this device' }), +) + +const hasPlatformAuth = pipe( + E.tryPromise(() => window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()), + E.filterOrFail( + identity, + () => new NotSupported({ message: 'No platform authenticator available on this device' }), + ), + E.asVoid, +) + +const hasConditionalUi = pipe( + E.tryPromise({ + try: () => window.PublicKeyCredential.isConditionalMediationAvailable(), + catch: () => + new NotSupported({ message: 'Conditional mediation not available on this device' }), + }), + E.filterOrFail( + identity, + () => new NotSupported({ message: 'Conditional mediation not available on this device' }), + ), + E.asVoid, +) + +export const passkeySupport = pipe( + hasWebAuthn, + E.andThen(hasPlatformAuth), + E.catchTag('UnknownException', e => E.die(e)), +) + +export const isPasskeySupport = pipe( + passkeySupport, + E.match({ + onFailure: () => false, + onSuccess: () => true, + }), +) + +export const autofillSupport = pipe(passkeySupport, E.andThen(hasConditionalUi)) + +export const isAutofillSupport = pipe( + autofillSupport, + E.match({ + onFailure: () => false, + onSuccess: () => true, + }), +) + +/* Live */ + +/* v8 ignore start */ +export const capabilitiesLive = Layer.succeed(Capabilities, { + passkeySupport, + isPasskeySupport, + autofillSupport, + isAutofillSupport, +}) +/* v8 ignore stop */ diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts new file mode 100644 index 0000000..83dcc62 --- /dev/null +++ b/packages/client/src/config.ts @@ -0,0 +1,42 @@ +import { RpcConfig } from '@passlock/shared/dist/rpc/config.js' +import { Context, Layer } from 'effect' + +export const DefaultEndpoint = 'https://api.passlock.dev' + +export type Tenancy = { + tenancyId: string + clientId: string +} +export const Tenancy = Context.GenericTag('@services/Tenancy') + +/** + * Allow developers to override the endpoint e.g. to + * point to a regional endpoint or a self-hosted backend + */ +export type Endpoint = { + endpoint?: string +} +export const Endpoint = Context.GenericTag('@services/Endpoint') + +export type Config = Tenancy & Endpoint +export const Config = Context.GenericTag('@services/Config') + +export const buildConfigLayers = (config: Config) => { + const tenancyLayer = Layer.succeed(Tenancy, Tenancy.of(config)) + const endpointLayer = Layer.succeed(Endpoint, Endpoint.of(config)) + return Layer.mergeAll(tenancyLayer, endpointLayer) +} + +export const buildRpcConfigLayers = (config: Config) => { + const endpoint = config.endpoint || DefaultEndpoint + return Layer.succeed( + RpcConfig, + RpcConfig.of({ + endpoint: endpoint, + tenancyId: config.tenancyId, + clientId: config.clientId, + }), + ) +} + +export type RequestDependencies = Endpoint | Tenancy | Storage | RpcConfig diff --git a/packages/client/src/connection/connection.fixture.ts b/packages/client/src/connection/connection.fixture.ts new file mode 100644 index 0000000..c4a45db --- /dev/null +++ b/packages/client/src/connection/connection.fixture.ts @@ -0,0 +1,17 @@ +import { ConnectionClient, ConnectRes } from '@passlock/shared/dist/rpc/connection.js' +import { Effect as E, Layer as L } from 'effect' + +export const preConnectRes = new ConnectRes({ warmed: true }) + +export const rpcClientTest = L.succeed( + ConnectionClient, + ConnectionClient.of({ + preConnect: () => E.succeed(preConnectRes), + }), +) + +export const rpcConfig = { + endpoint: 'https://example.com', + tenancyId: 'tenancyId', + clientId: 'clientId', +} diff --git a/packages/client/src/connection/connection.test.ts b/packages/client/src/connection/connection.test.ts new file mode 100644 index 0000000..be61b2a --- /dev/null +++ b/packages/client/src/connection/connection.test.ts @@ -0,0 +1,59 @@ +import { RpcConfig } from '@passlock/shared/dist/rpc/config.js' +import { ConnectionClient } from '@passlock/shared/dist/rpc/connection.js' +import { Dispatcher } from '@passlock/shared/dist/rpc/dispatcher.js' +import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect' +import { describe, expect, test } from 'vitest' +import { mock } from 'vitest-mock-extended' +import * as Fixture from './connection.fixture.js' +import { ConnectionService, ConnectionServiceLive } from './connection.js' + +describe('preConnect should', () => { + test('hit the rpc endpoint', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(ConnectionService) + yield* _(service.preConnect()) + + const rpcClient = yield* _(ConnectionClient) + expect(rpcClient.preConnect).toBeCalled() + + const dispatcher = yield* _(Dispatcher) + expect(dispatcher.get).toBeCalledWith(`/token/token?warm=true`) + }) + + const rpcClientTest = Layer.effect( + ConnectionClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.preConnect.mockReturnValue(E.succeed(Fixture.preConnectRes)) + + return rpcMock + }), + ) + + const rpcConfigTest = Layer.succeed(RpcConfig, RpcConfig.of(Fixture.rpcConfig)) + + const dispatcherTest = Layer.effect( + Dispatcher, + E.sync(() => { + const dispatcherMock = mock() + + dispatcherMock.get.mockReturnValue(E.succeed({ status: 200, body: {} })) + + return dispatcherMock + }), + ) + + const service = pipe( + ConnectionServiceLive, + L.provide(rpcClientTest), + L.provide(dispatcherTest), + L.provide(rpcConfigTest), + ) + + const layers = L.mergeAll(service, rpcClientTest, dispatcherTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) +}) diff --git a/packages/client/src/connection/connection.ts b/packages/client/src/connection/connection.ts new file mode 100644 index 0000000..1e45491 --- /dev/null +++ b/packages/client/src/connection/connection.ts @@ -0,0 +1,49 @@ +/** + * Hits the rpc endpoint to warm up a lambda + */ +import type { RpcConfig } from '@passlock/shared/dist/rpc/config.js' +import { ConnectionClient } from '@passlock/shared/dist/rpc/connection.js' +import { Dispatcher } from '@passlock/shared/dist/rpc/dispatcher.js' +import { Context, Effect as E, Layer, flow, pipe } from 'effect' + +/* Service */ + +export type ConnectionService = { + preConnect: () => E.Effect +} + +export const ConnectionService = Context.GenericTag('@services/ConnectService') + +/* Effects */ + +const hitPrincipal = pipe( + E.logInfo('Pre-connecting to Principal endpoint'), + E.zipRight(Dispatcher), + E.flatMap(dispatcher => dispatcher.get('/token/token?warm=true')), + E.asVoid, + E.catchAll(() => E.void), +) + +const hitRpc = pipe( + E.logInfo('Pre-connecting to RPC endpoint'), + E.zipRight(ConnectionClient), + E.flatMap(rpcClient => rpcClient.preConnect()), + E.asVoid, +) + +export const preConnect = () => pipe(E.all([hitPrincipal, hitRpc], { concurrency: 2 }), E.asVoid) + +/* Live */ + +/* v8 ignore start */ +export const ConnectionServiceLive = Layer.effect( + ConnectionService, + E.gen(function* (_) { + const context = yield* _(E.context()) + + return ConnectionService.of({ + preConnect: flow(preConnect, E.provide(context)), + }) + }), +) +/* v8 ignore stop */ diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts new file mode 100644 index 0000000..74a798e --- /dev/null +++ b/packages/client/src/effect.ts @@ -0,0 +1,285 @@ +import { create, get as getCredential } from '@github/webauthn-json/browser-ponyfill' + +import { AuthenticationClientLive } from '@passlock/shared/dist/rpc/authentication.js' +import { ConnectionClientLive } from '@passlock/shared/dist/rpc/connection.js' +import { RegistrationClientLive } from '@passlock/shared/dist/rpc/registration.js' +import { SocialClientLive } from '@passlock/shared/dist/rpc/social.js' +import { UserClientLive } from '@passlock/shared/dist/rpc/user.js' + +import { + Duplicate, + InternalBrowserError, + type BadRequest, + type Disabled, + type Forbidden, + type NotFound, + type NotSupported, + type Unauthorized, +} from '@passlock/shared/dist/error/error.js' + +import type { Principal } from '@passlock/shared/dist/schema/schema.js' + +import { Context, Effect as E, Layer as L, Layer, Schedule, pipe } from 'effect' +import type { NoSuchElementException } from 'effect/Cause' + +import { + AuthenticateServiceLive, + AuthenticationService, + GetCredential, + type AuthenticationRequest, +} from './authentication/authenticate.js' + +import { capabilitiesLive } from './capabilities/capabilities.js' +import { ConnectionService, ConnectionServiceLive } from './connection/connection.js' +import { EmailService, EmailServiceLive, URLQueryString, type VerifyRequest } from './email/email.js' + +import { + CreateCredential, + RegistrationService, + RegistrationServiceLive, + type RegistrationRequest, +} from './registration/register.js' + +import { + Storage, + StorageService, + StorageServiceLive, + type AuthType, + type StoredToken, +} from './storage/storage.js' + +import { RetrySchedule, RpcConfig } from '@passlock/shared/dist/rpc/config.js' +import { DispatcherLive } from '@passlock/shared/dist/rpc/dispatcher.js' +import { SocialService, SocialServiceLive, type RegisterOidcReq } from './social/social.js' +import { UserService, UserServiceLive, type Email } from './user/user.js' + +/* Layers */ + +const createCredentialLive = L.succeed( + CreateCredential, + CreateCredential.of((options: CredentialCreationOptions) => + pipe( + E.tryPromise({ + try: () => create(options), + catch: e => { + if (e instanceof Error && e.message.includes('excludeCredentials')) { + return new Duplicate({ + message: 'Passkey already registered to this device or cloud account', + }) + } else { + return new InternalBrowserError({ + message: 'Unable to create credential', + detail: String(e), + }) + } + }, + }), + E.map(credential => credential.toJSON()) + ), + ), +) + +const getCredentialLive = L.succeed( + GetCredential, + GetCredential.of((options: CredentialRequestOptions) => + pipe( + E.tryPromise({ + try: () => getCredential(options), + catch: e => + new InternalBrowserError({ + message: 'Unable to get authentication credential', + detail: String(e), + }), + }), + E.map(credential => credential.toJSON()), + ), + ), +) + +const schedule = Schedule.intersect(Schedule.recurs(3), Schedule.exponential('100 millis')) + +const retryScheduleLive = L.succeed(RetrySchedule, RetrySchedule.of({ schedule })) + +/* RPC Clients */ +const dispatcherLive = pipe(DispatcherLive, L.provide(retryScheduleLive)) +const connectClientLive = pipe(ConnectionClientLive, L.provide(dispatcherLive)) +const registerClientLive = pipe(RegistrationClientLive, L.provide(dispatcherLive)) +const authenticateClientLive = pipe(AuthenticationClientLive, L.provide(dispatcherLive)) +const socialClientLive = pipe(SocialClientLive, L.provide(dispatcherLive)) +const userClientLive = pipe(UserClientLive, L.provide(dispatcherLive)) + +const storageServiceLive = StorageServiceLive + +const userServiceLive = pipe(UserServiceLive, L.provide(userClientLive)) + +const registrationServiceLive = pipe( + RegistrationServiceLive, + L.provide(registerClientLive), + L.provide(userServiceLive), + L.provide(capabilitiesLive), + L.provide(createCredentialLive), + L.provide(storageServiceLive), +) + +const authenticationServiceLive = pipe( + AuthenticateServiceLive, + L.provide(authenticateClientLive), + L.provide(capabilitiesLive), + L.provide(getCredentialLive), + L.provide(storageServiceLive), +) + +const connectionServiceLive = pipe( + ConnectionServiceLive, + L.provide(connectClientLive), + L.provide(dispatcherLive), +) + +const urlQueryStringLive = Layer.succeed( + URLQueryString, + URLQueryString.of(E.sync(() => globalThis.window.location.search)), +) + +const emailServiceLive = pipe( + EmailServiceLive, + L.provide(urlQueryStringLive), + L.provide(userClientLive), + L.provide(capabilitiesLive), + L.provide(authenticationServiceLive), + L.provide(storageServiceLive), +) + +const socialServiceLive = pipe( + SocialServiceLive, + L.provide(socialClientLive), +) + +export const allRequirements = Layer.mergeAll( + capabilitiesLive, + userServiceLive, + registrationServiceLive, + authenticationServiceLive, + connectionServiceLive, + emailServiceLive, + storageServiceLive, + socialServiceLive, +) + +export class Config extends Context.Tag('Config')< + Config, + { + tenancyId: string + clientId: string + endpoint?: string + } +>() {} + +const storageLive = Layer.effect( + Storage, + E.sync(() => Storage.of(globalThis.localStorage)), +) + +const exchangeConfig = (effect: E.Effect) => { + return pipe( + Config, + E.flatMap(config => E.provideService(effect, RpcConfig, RpcConfig.of(config))), + effect => E.provide(effect, storageLive), + ) +} + +export const preConnect = (): E.Effect => + pipe( + ConnectionService, + E.flatMap(service => service.preConnect()), + E.provide(connectionServiceLive), + exchangeConfig, + ) + +export const isRegistered = (email: Email): E.Effect => + pipe( + UserService, + E.flatMap(service => service.isExistingUser(email)), + E.provide(userServiceLive), + exchangeConfig, + ) + +export type RegistrationErrors = NotSupported | BadRequest | Duplicate | Unauthorized | Forbidden + +export const registerPasskey = ( + request: RegistrationRequest, +): E.Effect => + pipe( + RegistrationService, + E.flatMap(service => service.registerPasskey(request)), + E.provide(registrationServiceLive), + exchangeConfig, + ) + +export type AuthenticationErrors = + | NotSupported + | BadRequest + | NotFound + | Disabled + | Unauthorized + | Forbidden + +export const authenticatePasskey = ( + request: AuthenticationRequest, +): E.Effect => + pipe( + AuthenticationService, + E.flatMap(service => service.authenticatePasskey(request)), + E.provide(authenticationServiceLive), + exchangeConfig, + ) + +export type VerifyEmailErrors = + | NotSupported + | BadRequest + | NotFound + | Disabled + | Unauthorized + | Forbidden + +export const verifyEmailCode = ( + request: VerifyRequest, +): E.Effect => + pipe( + EmailService, + E.flatMap(service => service.verifyEmailCode(request)), + E.provide(emailServiceLive), + exchangeConfig, + ) + +export const verifyEmailLink = (): E.Effect => + pipe( + EmailService, + E.flatMap(service => service.verifyEmailLink()), + E.provide(emailServiceLive), + exchangeConfig, + ) + +export const getSessionToken = ( + authType: AuthType, +): E.Effect => + pipe( + StorageService, + E.flatMap(service => service.getToken(authType)), + E.provide(storageServiceLive), + E.provide(storageLive), + ) + +export const clearExpiredTokens = (): E.Effect => + pipe( + StorageService, + E.flatMap(service => service.clearExpiredTokens), + E.provide(storageServiceLive), + E.provide(storageLive), + ) + +export const authenticateOIDC = (request: RegisterOidcReq) => + pipe( + SocialService, + E.flatMap(service => service.registerOidc(request)), + E.provide(socialServiceLive) + ) \ No newline at end of file diff --git a/packages/client/src/email/email.fixture.ts b/packages/client/src/email/email.fixture.ts new file mode 100644 index 0000000..ed65821 --- /dev/null +++ b/packages/client/src/email/email.fixture.ts @@ -0,0 +1,41 @@ +import { UserClient, VerifyEmailReq, VerifyEmailRes } from '@passlock/shared/dist/rpc/user.js' +import { Effect as E, Layer as L } from 'effect' +import { AuthenticationService } from '../authentication/authenticate.js' +import * as Fixtures from '../test/fixtures.js' +import { URLQueryString } from './email.js' + +export const token = 'token' +export const code = 'code' +export const authType = 'passkey' +export const expireAt = Date.now() + 10000 + +export const locationSearchTest = L.succeed( + URLQueryString, + URLQueryString.of(E.succeed(`?code=${code}`)), +) + +export const authenticationServiceTest = L.succeed( + AuthenticationService, + AuthenticationService.of({ + authenticatePasskey: () => E.succeed(Fixtures.principal), + }), +) + +export const rpcVerifyEmailReq = new VerifyEmailReq({ token, code }) + +export const rpcVerifyEmailRes = new VerifyEmailRes({ principal: Fixtures.principal }) + +export const rpcClientTest = L.succeed( + UserClient, + UserClient.of({ + isExistingUser: () => E.succeed({ existingUser: true }), + verifyEmail: () => E.succeed(rpcVerifyEmailRes), + resendVerificationEmail: () => E.fail(Fixtures.notImplemented), + }), +) + +export const principal = Fixtures.principal + +export const storedToken = Fixtures.storedToken + +export const storageServiceTest = Fixtures.storageServiceTest diff --git a/packages/client/src/email/email.test.ts b/packages/client/src/email/email.test.ts new file mode 100644 index 0000000..72ebd0e --- /dev/null +++ b/packages/client/src/email/email.test.ts @@ -0,0 +1,185 @@ +import { UserClient } from '@passlock/shared/dist/rpc/user.js' +import { Effect as E, Layer as L, LogLevel, Logger, pipe } from 'effect' +import { NoSuchElementException } from 'effect/Cause' +import { describe, expect, test } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { AuthenticationService } from '../authentication/authenticate.js' +import { StorageService } from '../storage/storage.js' +import * as Fixture from './email.fixture.js' +import { EmailService, EmailServiceLive } from './email.js' + +describe('verifyEmailCode should', () => { + test('return a principal when the verification is successful', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(EmailService) + const result = yield* _(service.verifyEmailCode({ code: '123' })) + + expect(result).toEqual(Fixture.principal) + }) + + const service = pipe( + EmailServiceLive, + L.provide(Fixture.locationSearchTest), + L.provide(Fixture.authenticationServiceTest), + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.rpcClientTest), + ) + + const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('check for a token in local storage', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(EmailService) + yield* _(service.verifyEmailCode({ code: '123' })) + + const storageService = yield* _(StorageService) + expect(storageService.getToken).toHaveBeenCalledWith('passkey') + }) + + const storageServiceTest = L.effect( + StorageService, + E.sync(() => { + const storageServiceMock = mock() + + storageServiceMock.getToken.mockReturnValue(E.succeed(Fixture.storedToken)) + storageServiceMock.clearToken.mockReturnValue(E.void) + + return storageServiceMock + }), + ) + + const service = pipe( + EmailServiceLive, + L.provide(Fixture.locationSearchTest), + L.provide(Fixture.authenticationServiceTest), + L.provide(storageServiceTest), + L.provide(Fixture.rpcClientTest), + ) + + const layers = L.merge(service, storageServiceTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('re-authenticate the user if no local token', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(EmailService) + yield* _(service.verifyEmailCode({ code: '123' })) + + const authService = yield* _(AuthenticationService) + expect(authService.authenticatePasskey).toHaveBeenCalled() + }) + + const storageServiceTest = L.effect( + StorageService, + E.sync(() => { + const storageServiceMock = mock() + + storageServiceMock.getToken.mockReturnValue(E.fail(new NoSuchElementException())) + storageServiceMock.clearToken.mockReturnValue(E.void) + + return storageServiceMock + }), + ) + + const authServiceTest = L.effect( + AuthenticationService, + E.sync(() => { + const authServiceMock = mock() + + authServiceMock.authenticatePasskey.mockReturnValue(E.succeed(Fixture.principal)) + + return authServiceMock + }), + ) + + const service = pipe( + EmailServiceLive, + L.provide(Fixture.locationSearchTest), + L.provide(authServiceTest), + L.provide(storageServiceTest), + L.provide(Fixture.rpcClientTest), + ) + + const layers = L.mergeAll(service, storageServiceTest, authServiceTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('call the backend', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(EmailService) + yield* _(service.verifyEmailCode({ code: Fixture.code })) + + const rpcClient = yield* _(UserClient) + expect(rpcClient.verifyEmail).toHaveBeenCalledWith(Fixture.rpcVerifyEmailReq) + }) + + const rpcClientTest = L.effect( + UserClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.verifyEmail.mockReturnValue(E.succeed(Fixture.rpcVerifyEmailRes)) + + return rpcMock + }), + ) + + const service = pipe( + EmailServiceLive, + L.provide(Fixture.locationSearchTest), + L.provide(Fixture.authenticationServiceTest), + L.provide(Fixture.storageServiceTest), + L.provide(rpcClientTest), + ) + + const layers = L.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) +}) + +describe('verifyEmailLink should', () => { + test('extract the code from the current url', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(EmailService) + yield* _(service.verifyEmailLink()) + + // LocationSearch return ?code=code + // and we expect rpcClient to be called with code + const rpcClient = yield* _(UserClient) + expect(rpcClient.verifyEmail).toBeCalledWith(Fixture.rpcVerifyEmailReq) + }) + + const rpcClientTest = L.effect( + UserClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.verifyEmail.mockReturnValue(E.succeed(Fixture.rpcVerifyEmailRes)) + + return rpcMock + }), + ) + + const service = pipe( + EmailServiceLive, + L.provide(Fixture.locationSearchTest), + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.authenticationServiceTest), + L.provide(rpcClientTest), + ) + + const layers = L.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) +}) diff --git a/packages/client/src/email/email.ts b/packages/client/src/email/email.ts new file mode 100644 index 0000000..c357a0d --- /dev/null +++ b/packages/client/src/email/email.ts @@ -0,0 +1,138 @@ +/** + * Email verification effects + */ +import { BadRequest } from '@passlock/shared/dist/error/error.js' +import type { VerifyEmailErrors as RpcErrors } from '@passlock/shared/dist/rpc/user.js' +import { UserClient, VerifyEmailReq } from '@passlock/shared/dist/rpc/user.js' +import type { Principal } from '@passlock/shared/dist/schema/schema.js' +import { Context, Effect as E, Layer, Option as O, flow, identity, pipe } from 'effect' +import { AuthenticationService, type AuthenticationErrors } from '../authentication/authenticate.js' +import { StorageService } from '../storage/storage.js' + +/* Requests */ + +export type VerifyRequest = { + code: string +} + +/* Errors */ + +export type VerifyEmailErrors = RpcErrors | AuthenticationErrors + +/* Dependencies */ + +export class URLQueryString extends Context.Tag('URLQueryString')< + URLQueryString, + E.Effect +>() {} + +/* Service */ + +export type EmailService = { + verifyEmailCode: (request: VerifyRequest) => E.Effect + verifyEmailLink: () => E.Effect +} + +export const EmailService = Context.GenericTag('@services/EmailService') + +/* Utils */ + +export type Dependencies = StorageService | AuthenticationService | UserClient + +/** + * Check for existing token in sessionStorage, + * otherwise force passkey re-authentication + * @returns + */ +const getToken = () => { + return E.gen(function* (_) { + // Check for existing token + const storageService = yield* _(StorageService) + const existingTokenE = storageService.getToken('passkey') + const authenticationService = yield* _(AuthenticationService) + + const tokenE = E.matchEffect(existingTokenE, { + onSuccess: token => E.succeed(token), + onFailure: () => + // No token, need to authenticate the user + pipe( + authenticationService.authenticatePasskey({ userVerification: 'preferred' }), + E.map(principal => ({ + token: principal.token, + authType: principal.authStatement.authType, + expiresAt: principal.expireAt.getTime(), + })), + ), + }) + + const token = yield* _(tokenE) + yield* _(storageService.clearToken('passkey')) + + return token + }) +} + +/** + * Look for ?code= in the url + * @returns + */ +export const extractCodeFromHref = () => { + return pipe( + URLQueryString, + E.flatMap(identity), + E.map(search => new URLSearchParams(search)), + E.flatMap(params => O.fromNullable(params.get('code'))), + ) +} + +/* Effects */ + +/** + * Verify the mailbox using the given code + * @param request + * @returns + */ +export const verifyEmail = ( + request: VerifyRequest, +): E.Effect => { + return E.gen(function* (_) { + // Re-authenticate the user if required + const { token } = yield* _(getToken()) + + yield* _(E.logDebug('Making request')) + const client = yield* _(UserClient) + const { principal } = yield* _( + client.verifyEmail(new VerifyEmailReq({ token, code: request.code })), + ) + + return principal + }) +} + +/** + * Look for a code in the current url and verify it + * @returns + */ +export const verifyEmailLink = () => + pipe( + extractCodeFromHref(), + E.mapError(() => new BadRequest({ message: 'Expected ?code=xxx in window.location' })), + E.flatMap(code => verifyEmail({ code })), + ) + +/* Live */ + +/* v8 ignore start */ +export const EmailServiceLive = Layer.effect( + EmailService, + E.gen(function* (_) { + const context = yield* _( + E.context(), + ) + return EmailService.of({ + verifyEmailCode: flow(verifyEmail, E.provide(context)), + verifyEmailLink: flow(verifyEmailLink, E.provide(context)), + }) + }), +) +/* v8 ignore stop */ diff --git a/packages/client/src/event/event.node.test.ts b/packages/client/src/event/event.node.test.ts new file mode 100644 index 0000000..a8918f3 --- /dev/null +++ b/packages/client/src/event/event.node.test.ts @@ -0,0 +1,20 @@ +import { Effect, pipe } from 'effect' +import { describe, expect, test } from 'vitest' +import { fireEvent } from './event.js' + +// @vitest-environment node + +describe('isPasslockEvent', () => { + test("return a Passlock error if custom events aren't supported", async () => { + const program = pipe( + fireEvent('hello world'), + Effect.flip, + Effect.tap(e => { + expect(e._tag).toBe('InternalBrowserError') + expect(e.message).toBe('Unable to fire custom event') + }), + ) + + await Effect.runPromise(program) + }) +}) diff --git a/packages/client/src/event/event.test.ts b/packages/client/src/event/event.test.ts new file mode 100644 index 0000000..430244e --- /dev/null +++ b/packages/client/src/event/event.test.ts @@ -0,0 +1,36 @@ +import { Effect } from 'effect' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { DebugMessage, fireEvent, isPasslockEvent } from './event.js' + +describe('fireEvent', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + test('fire a custom log event', () => { + const effect = fireEvent('hello world') + const eventSpy = vi.spyOn(globalThis, 'dispatchEvent') + Effect.runSync(effect) + + const expectedEvent = new CustomEvent(DebugMessage, { + detail: 'hello world', + }) + + expect(eventSpy).toHaveBeenCalledWith(expectedEvent) + }) +}) + +describe('isPasslockEvent', () => { + test('return true for our custom event', () => { + const passlockEvent = new CustomEvent(DebugMessage, { + detail: 'hello world', + }) + + expect(isPasslockEvent(passlockEvent)).toBe(true) + }) + + test('return false for other events', () => { + const otherEvent = new MouseEvent('click') + expect(isPasslockEvent(otherEvent)).toBe(false) + }) +}) diff --git a/packages/client/src/event/event.ts b/packages/client/src/event/event.ts new file mode 100644 index 0000000..60dacbc --- /dev/null +++ b/packages/client/src/event/event.ts @@ -0,0 +1,24 @@ +/** + * Fire DOM events + */ +import { InternalBrowserError } from '@passlock/shared/dist/error/error.js' +import { Effect } from 'effect' + +export const DebugMessage = 'PasslogDebugMessage' + +export const fireEvent = (message: string) => { + return Effect.try({ + try: () => { + const evt = new CustomEvent(DebugMessage, { detail: message }) + globalThis.dispatchEvent(evt) + }, + catch: () => { + return new InternalBrowserError({ message: 'Unable to fire custom event' }) + }, + }) +} + +export function isPasslockEvent(event: Event): event is CustomEvent { + if (event.type !== DebugMessage) return false + return 'detail' in event +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..5c0701a --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,349 @@ +import type { + BadRequest, + Disabled, + Duplicate, + Forbidden, + NotFound, + NotSupported, + Unauthorized, +} from '@passlock/shared/dist/error/error.js' + +import { ErrorCode } from '@passlock/shared/dist/error/error.js' +import { RpcConfig } from '@passlock/shared/dist/rpc/config.js' +import type { Principal } from '@passlock/shared/dist/schema/schema.js' +import { Effect as E, Layer as L, Layer, Option, Runtime, Scope, pipe } from 'effect' +import { AuthenticationService, type AuthenticationRequest } from './authentication/authenticate.js' +import { Capabilities } from './capabilities/capabilities.js' +import { ConnectionService } from './connection/connection.js' +import { allRequirements } from './effect.js' +import { EmailService, type VerifyRequest } from './email/email.js' +import { RegistrationService, type RegistrationRequest } from './registration/register.js' +import { SocialService, type AuthenticateOidcReq, type RegisterOidcReq } from './social/social.js' +import { Storage, StorageService, type AuthType, type StoredToken } from './storage/storage.js' +import { UserService, type Email, type ResendEmail } from './user/user.js' + +/* Exports */ + +export type Options = { signal?: AbortSignal } +export type { Principal, UserVerification, VerifyEmail } from '@passlock/shared/dist/schema/schema.js' +export type { AuthenticationRequest } from './authentication/authenticate.js' +export type { VerifyRequest } from './email/email.js' +export type { RegistrationRequest } from './registration/register.js' +export type { AuthType, StoredToken } from './storage/storage.js' +export type { Email } from './user/user.js' + +export type PasslockProps = { + tenancyId: string; + clientId: string; + endpoint?: string +} + +export { ErrorCode } from '@passlock/shared/dist/error/error.js' + +export class PasslockError extends Error { + readonly code: ErrorCode + readonly detail: string | undefined + + constructor(message: string, code: ErrorCode, detail?: string) { + super(message) + this.code = code + this.detail = detail + } + + static readonly isError = (error: unknown): error is PasslockError => { + return ( + typeof error === 'object' && + error !== null && + error instanceof PasslockError + ) + } +} + +/* // Exports */ + +type PasslockErrors = + | BadRequest + | NotSupported + | Duplicate + | Unauthorized + | Forbidden + | Disabled + | NotFound + +const hasMessage = (defect: unknown): defect is { message: string } => { + return ( + typeof defect === 'object' && + defect !== null && + 'message' in defect && + typeof defect['message'] === 'string' + ) +} + +const transformErrors = ( + effect: E.Effect, +): E.Effect => { + const withErrorHandling = E.catchTags(effect, { + NotSupported: e => E.succeed(new PasslockError(e.message, ErrorCode.NotSupported)), + BadRequest: e => E.succeed(new PasslockError(e.message, ErrorCode.BadRequest, e.detail)), + Duplicate: e => E.succeed(new PasslockError(e.message, ErrorCode.Duplicate, e.detail)), + Unauthorized: e => E.succeed(new PasslockError(e.message, ErrorCode.Unauthorized, e.detail)), + Forbidden: e => E.succeed(new PasslockError(e.message, ErrorCode.Forbidden, e.detail)), + Disabled: e => E.succeed(new PasslockError(e.message, ErrorCode.Disabled, e.detail)), + NotFound: e => E.succeed(new PasslockError(e.message, ErrorCode.NotFound, e.detail)), + }) + + const sandboxed = E.sandbox(withErrorHandling) + + const withSandboxing = E.catchTags(sandboxed, { + Die: ({ defect }) => { + return hasMessage(defect) + ? E.succeed(new PasslockError(defect.message, ErrorCode.InternalServerError)) + : E.succeed(new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError)) + }, + + Interrupt: () => { + return E.succeed(new PasslockError('Operation aborted', ErrorCode.InternalBrowserError)) + }, + + Sequential: errors => { + console.error(errors) + return E.succeed( + new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError), + ) + }, + + Parallel: errors => { + console.error(errors) + return E.succeed( + new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError), + ) + }, + }) + + return E.unsandbox(withSandboxing) +} + +type Requirements = + | UserService + | RegistrationService + | AuthenticationService + | ConnectionService + | EmailService + | StorageService + | Capabilities + | SocialService + +export class PasslockUnsafe { + private readonly runtime: Runtime.Runtime + + constructor(config: PasslockProps) { + const rpcConfig = Layer.succeed(RpcConfig, RpcConfig.of(config)) + const storage = Layer.succeed(Storage, Storage.of(globalThis.localStorage)) + const allLayers = pipe(allRequirements, L.provide(rpcConfig), L.provide(storage)) + const scope = E.runSync(Scope.make()) + this.runtime = E.runSync(Layer.toRuntime(allLayers).pipe(Scope.extend(scope))) + } + + private readonly runPromise = ( + effect: E.Effect, + options: Options | undefined = undefined + ) => { + return pipe( + transformErrors(effect), + E.flatMap(result => (PasslockError.isError(result) ? E.fail(result) : E.succeed(result))), + effect => Runtime.runPromise(this.runtime)(effect, options), + ) + } + + preConnect = (options?: Options): Promise => + pipe( + ConnectionService, + E.flatMap(service => service.preConnect()), + effect => Runtime.runPromise(this.runtime)(effect, options), + ) + + isPasskeySupport = (): Promise => + pipe( + Capabilities, + E.flatMap(service => service.isPasskeySupport), + effect => Runtime.runPromise(this.runtime)(effect), + ) + + isExistingUser = (email: Email, options?: Options): Promise => + pipe( + UserService, + E.flatMap(service => service.isExistingUser(email)), + effect => this.runPromise(effect, options), + ) + + registerPasskey = (request: RegistrationRequest, options?: Options): Promise => + pipe( + RegistrationService, + E.flatMap(service => service.registerPasskey(request)), + effect => this.runPromise(effect, options), + ) + + authenticatePasskey = (request: AuthenticationRequest, options?: Options): Promise => + pipe( + AuthenticationService, + E.flatMap(service => service.authenticatePasskey(request)), + effect => this.runPromise(effect, options), + ) + + registerOidc = (request: RegisterOidcReq, options?: Options) => + pipe( + SocialService, + E.flatMap(service => service.registerOidc(request)), + effect => this.runPromise(effect, options), + ) + + authenticateOidc = (request: AuthenticateOidcReq, options?: Options) => + pipe( + SocialService, + E.flatMap(service => service.authenticateOidc(request)), + effect => this.runPromise(effect, options), + ) + + verifyEmailCode = (request: VerifyRequest, options?: Options): Promise => + pipe( + EmailService, + E.flatMap(service => service.verifyEmailCode(request)), + effect => this.runPromise(effect, options), + ) + + resendVerificationEmail = (request: ResendEmail, options?: Options): Promise => + pipe( + UserService, + E.flatMap(service => service.resendVerificationEmail(request)), + effect => this.runPromise(effect, options), + ) + + verifyEmailLink = (options?: Options): Promise => + pipe( + EmailService, + E.flatMap(service => service.verifyEmailLink()), + effect => this.runPromise(effect, options), + ) + + getSessionToken = (authType: AuthType): StoredToken | undefined => + pipe( + StorageService, + E.flatMap(service => service.getToken(authType).pipe(effect => E.option(effect))), + E.map(Option.getOrUndefined), + effect => Runtime.runSync(this.runtime)(effect), + ) + + clearExpiredTokens = (): void => + pipe( + StorageService, + E.flatMap(service => service.clearExpiredTokens), + effect => Runtime.runSync(this.runtime)(effect), + ) +} + +export class Passlock { + private readonly runtime: Runtime.Runtime + + constructor(config: PasslockProps) { + const rpcConfig = Layer.succeed(RpcConfig, RpcConfig.of(config)) + const storage = Layer.succeed(Storage, Storage.of(globalThis.localStorage)) + const allLayers = pipe(allRequirements, L.provide(rpcConfig), L.provide(storage)) + const scope = E.runSync(Scope.make()) + this.runtime = E.runSync(Layer.toRuntime(allLayers).pipe(Scope.extend(scope))) + } + + private readonly runPromise = ( + effect: E.Effect, + options: Options | undefined = undefined + ) => { + return pipe( + transformErrors(effect), + effect => Runtime.runPromise(this.runtime)(effect, options) + ) + } + + preConnect = (options?: Options): Promise => + pipe( + ConnectionService, + E.flatMap(service => service.preConnect()), + effect => this.runPromise(effect, options), + ) + + isPasskeySupport = (): Promise => + pipe( + Capabilities, + E.flatMap(service => service.isPasskeySupport), + effect => Runtime.runPromise(this.runtime)(effect), + ) + + isExistingUser = (email: Email, options?: Options): Promise => + pipe( + UserService, + E.flatMap(service => service.isExistingUser(email)), + effect => this.runPromise(effect, options), + ) + + registerPasskey = (request: RegistrationRequest, options?: Options): Promise => + pipe( + RegistrationService, + E.flatMap(service => service.registerPasskey(request)), + effect => this.runPromise(effect, options), + ) + + authenticatePasskey = (request: AuthenticationRequest = {}, options?: Options): Promise => + pipe( + AuthenticationService, + E.flatMap(service => service.authenticatePasskey(request)), + effect => this.runPromise(effect, options), + ) + + registerOidc = (request: RegisterOidcReq, options?: Options) => + pipe( + SocialService, + E.flatMap(service => service.registerOidc(request)), + effect => this.runPromise(effect, options), + ) + + authenticateOidc = (request: AuthenticateOidcReq, options?: Options) => + pipe( + SocialService, + E.flatMap(service => service.authenticateOidc(request)), + effect => this.runPromise(effect, options), + ) + + verifyEmailCode = (request: VerifyRequest, options?: Options): Promise => + pipe( + EmailService, + E.flatMap(service => service.verifyEmailCode(request)), + effect => this.runPromise(effect, options), + ) + + verifyEmailLink = (options?: Options): Promise => + pipe( + EmailService, + E.flatMap(service => service.verifyEmailLink()), + effect => this.runPromise(effect, options), + ) + + resendVerificationEmail = (request: ResendEmail, options?: Options): Promise => + pipe( + UserService, + E.flatMap(service => service.resendVerificationEmail(request)), + effect => this.runPromise(effect, options), + ) + + getSessionToken = (authType: AuthType): StoredToken | undefined => + pipe( + StorageService, + E.flatMap(service => service.getToken(authType).pipe(effect => E.option(effect))), + E.map(maybeToken => Option.getOrUndefined(maybeToken)), + effect => Runtime.runSync(this.runtime)(effect), + ) + + clearExpiredTokens = (): void => + pipe( + StorageService, + E.flatMap(service => service.clearExpiredTokens), + effect => Runtime.runSync(this.runtime)(effect), + ) +} diff --git a/packages/client/src/logging/eventLogger.test.ts b/packages/client/src/logging/eventLogger.test.ts new file mode 100644 index 0000000..f2736b5 --- /dev/null +++ b/packages/client/src/logging/eventLogger.test.ts @@ -0,0 +1,103 @@ +import { Effect as E, LogLevel, Logger } from 'effect' +import { describe, expect, test, vi } from 'vitest' +import { eventLoggerLive, logRaw } from './eventLogger.js' + +/** + * Although the core log functionality is tested alongside the logger in the @passlock/shared + * package, those tests deliberately exclude the event dispatch elements as the package + * is intended to be agnostic to the runtime environment. This client package however is + * intented to be run in the browser, so we can plugin a real event dispatcher and ensure + * it's working as expected. + */ + +describe('log', () => { + test('log DEBUG to the console', () => { + const logStatement = E.logDebug('hello world') + + const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined) + const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Debug)) + + const effect = E.provide(withLogLevel, eventLoggerLive) + E.runSync(effect) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world')) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('DEBUG')) + }) + + test('log INFO to the console', () => { + const logStatement = E.logInfo('hello world') + + const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined) + const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Info)) + + const effect = E.provide(withLogLevel, eventLoggerLive) + E.runSync(effect) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world')) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('INFO')) + }) + + test('log WARN to the console', () => { + const logStatement = E.logWarning('hello world') + + const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined) + const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Warning)) + + const effect = E.provide(withLogLevel, eventLoggerLive) + E.runSync(effect) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world')) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('WARN')) + }) + + test('log ERROR to the console', () => { + const logStatement = E.logError('hello world') + + const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined) + const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Error)) + + const effect = E.provide(withLogLevel, eventLoggerLive) + E.runSync(effect) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world')) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('ERROR')) + }) + + test('log raw data to the console', () => { + const logStatement = logRaw('hello world') + const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined) + + E.runSync(logStatement) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world')) + }) + + test('fire a custom log event', () => { + const logStatement = E.logWarning('hello world') + + const eventSpy = vi.spyOn(globalThis, 'dispatchEvent').mockImplementation(() => false) + const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Warning)) + + const effect = E.provide(withLogLevel, eventLoggerLive) + + E.runSync(effect) + + const expectedEvent = new CustomEvent('PasslogDebugMessage', { + detail: 'hello world', + }) + + expect(eventSpy).toHaveBeenCalledWith(expectedEvent) + }) + + test('not fire a log event for a debug message', () => { + const logStatement = E.logDebug('hello world') + + const eventSpy = vi.spyOn(globalThis, 'dispatchEvent').mockImplementation(() => false) + const withDebugLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Debug)) + + const effect = E.provide(withDebugLevel, eventLoggerLive) + E.runSync(effect) + + expect(eventSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/src/logging/eventLogger.ts b/packages/client/src/logging/eventLogger.ts new file mode 100644 index 0000000..d405fbb --- /dev/null +++ b/packages/client/src/logging/eventLogger.ts @@ -0,0 +1,41 @@ +/** + * Logger implementation that also fires DOM events. + * This is useful to allow external code to plug into the logging + * mechanism. E.g. the Passlock demo subscribes to events to generate + * a typewriter style effect + */ +import { Effect as E, LogLevel, Logger } from 'effect' + +/** + * Some log messages span multiple lines/include json etc which is + * better output without being formatted by Effect's logging framework + * + * @param message + * @returns + */ +export const logRaw = (message: T) => { + return E.sync(() => { + console.log(message) + }) +} + +export const DebugMessage = 'PasslogDebugMessage' + +const dispatch = (message: string) => { + try { + const evt = new CustomEvent(DebugMessage, { detail: message }) + globalThis.dispatchEvent(evt) + } catch (e) { + globalThis.console.log('Unable to fire custom event') + } +} + +export const eventLoggerLive = Logger.add( + Logger.make(({ logLevel, message }) => { + if (typeof message === 'string' && logLevel !== LogLevel.Debug) { + dispatch(message) + } else if (Array.isArray(message) && logLevel !== LogLevel.Debug) { + message.forEach(dispatch) + } + }) +) diff --git a/packages/client/src/registration/register.fixture.ts b/packages/client/src/registration/register.fixture.ts new file mode 100644 index 0000000..a866e01 --- /dev/null +++ b/packages/client/src/registration/register.fixture.ts @@ -0,0 +1,88 @@ +import { + OptionsReq, + OptionsRes, + RegistrationClient, + VerificationReq, + VerificationRes, +} from '@passlock/shared/dist/rpc/registration.js' +import type { RegistrationCredential } from '@passlock/shared/dist/schema/schema.js' +import { Effect as E, Layer as L } from 'effect' +import * as Fixtures from '../test/fixtures.js' +import { UserService } from '../user/user.js' +import { CreateCredential, type RegistrationRequest } from './register.js' + +export const session = 'session' +export const token = 'token' +export const code = 'code' +export const authType = 'passkey' +export const expireAt = Date.now() + 10000 + +export const registrationRequest: RegistrationRequest = { + email: 'jdoe@gmail.com', + givenName: 'john', + familyName: 'doe', +} + +export const rpcOptionsReq = new OptionsReq(registrationRequest) + +export const registrationOptions: OptionsRes = { + session, + publicKey: { + rp: { + name: 'passlock', + id: 'passlock.dev', + }, + user: { + name: 'john doe', + id: 'jdoe', + displayName: 'john doe', + }, + challenge: 'FKZSl_saKu5OXjLLwoq8eK3wlD8XgpGiS10SszW5RiE', + pubKeyCredParams: [], + }, +} + +export const rpcOptionsRes = new OptionsRes(registrationOptions) + +export const credential: RegistrationCredential = { + type: 'public-key', + id: '1', + rawId: '1', + response: { + transports: [], + clientDataJSON: '', + attestationObject: '', + }, + clientExtensionResults: {}, +} + +export const rpcVerificationReq = new VerificationReq({ session, credential }) + +export const rpcVerificationRes = new VerificationRes({ principal: Fixtures.principal }) + +export const createCredentialTest = L.succeed( + CreateCredential, + CreateCredential.of(() => E.succeed(credential)), +) + +export const userServiceTest = L.succeed( + UserService, + UserService.of({ + isExistingUser: () => E.succeed(false), + resendVerificationEmail: () => E.succeed(true) + }), +) + +export const rpcClientTest = L.succeed( + RegistrationClient, + RegistrationClient.of({ + getRegistrationOptions: () => E.succeed(rpcOptionsRes), + verifyRegistrationCredential: () => E.succeed(rpcVerificationRes), + }) +) + +export const principal = Fixtures.principal + +export const capabilitiesTest = Fixtures.capabilitiesTest + +export const storageServiceTest = Fixtures.storageServiceTest diff --git a/packages/client/src/registration/register.test.ts b/packages/client/src/registration/register.test.ts new file mode 100644 index 0000000..31d9808 --- /dev/null +++ b/packages/client/src/registration/register.test.ts @@ -0,0 +1,210 @@ +import { Duplicate, InternalBrowserError } from '@passlock/shared/dist/error/error.js' +import { RegistrationClient } from '@passlock/shared/dist/rpc/registration.js' +import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect' +import { describe, expect, test, vi } from 'vitest' +import { mock } from 'vitest-mock-extended' +import * as Fixture from './register.fixture.js' +import { CreateCredential, RegistrationService, RegistrationServiceLive } from './register.js' + +describe('register should', () => { + test('return a valid credential', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(RegistrationService) + const result = yield* _(service.registerPasskey(Fixture.registrationRequest)) + expect(result).toEqual(Fixture.principal) + }) + + const service = pipe( + RegistrationServiceLive, + L.provide(Fixture.createCredentialTest), + L.provide(Fixture.userServiceTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.rpcClientTest), + ) + + const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('pass the registration data to the backend', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(RegistrationService) + yield* _(service.registerPasskey(Fixture.registrationRequest)) + + const rpcClient = yield* _(RegistrationClient) + expect(rpcClient.getRegistrationOptions).toHaveBeenCalledWith(Fixture.rpcOptionsReq) + }) + + const rpcClientTest = L.effect( + RegistrationClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.getRegistrationOptions.mockReturnValue(E.succeed(Fixture.rpcOptionsRes)) + rpcMock.verifyRegistrationCredential.mockReturnValue(E.succeed(Fixture.rpcVerificationRes)) + + return rpcMock + }), + ) + + const service = pipe( + RegistrationServiceLive, + L.provide(Fixture.createCredentialTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.userServiceTest), + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('send the new credential to the backend', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(RegistrationService) + yield* _(service.registerPasskey(Fixture.registrationRequest)) + + const rpcClient = yield* _(RegistrationClient) + expect(rpcClient.verifyRegistrationCredential).toHaveBeenCalledWith(Fixture.rpcVerificationReq) + }) + + const rpcClientTest = L.effect( + RegistrationClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.getRegistrationOptions.mockReturnValue(E.succeed(Fixture.rpcOptionsRes)) + rpcMock.verifyRegistrationCredential.mockReturnValue(E.succeed(Fixture.rpcVerificationRes)) + + return rpcMock + }), + ) + + const service = pipe( + RegistrationServiceLive, + L.provide(Fixture.createCredentialTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.userServiceTest), + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('short-circuit if the user is already registered', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(RegistrationService) + + const error = yield* _(service.registerPasskey(Fixture.registrationRequest), E.flip) + + expect(error).toBeInstanceOf(Duplicate) + }) + + const rpcClientTest = L.effect( + RegistrationClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.getRegistrationOptions.mockReturnValue(E.fail(new Duplicate({ message: 'User already exists' }))) + + return rpcMock + }), + ) + + const service = pipe( + RegistrationServiceLive, + L.provide(Fixture.createCredentialTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.userServiceTest), + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('return an error if we try to re-register a credential', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(RegistrationService) + + const defect = yield* _(service.registerPasskey(Fixture.registrationRequest), E.flip) + + expect(defect).toBeInstanceOf(Duplicate) + }) + + const createTest = L.effect( + CreateCredential, + E.sync(() => { + const createTest = vi.fn() + + createTest.mockReturnValue(E.fail(new Duplicate({ message: 'boom!' }))) + + return createTest + }), + ) + + const service = pipe( + RegistrationServiceLive, + L.provide(Fixture.userServiceTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.rpcClientTest), + L.provide(createTest), + ) + + const layers = Layer.merge(service, createTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test("throw an error if the browser can't create a credential", async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(RegistrationService) + + const defect = yield* _( + service.registerPasskey(Fixture.registrationRequest), + E.catchAllDefect(defect => E.succeed(defect)), + ) + + expect(defect).toBeInstanceOf(InternalBrowserError) + }) + + const createTest = L.effect( + CreateCredential, + E.sync(() => { + const createTest = vi.fn() + + createTest.mockReturnValue(E.fail(new InternalBrowserError({ message: 'boom!' }))) + + return createTest + }), + ) + + const service = pipe( + RegistrationServiceLive, + L.provide(Fixture.userServiceTest), + L.provide(Fixture.capabilitiesTest), + L.provide(Fixture.storageServiceTest), + L.provide(Fixture.rpcClientTest), + L.provide(createTest), + ) + + const layers = Layer.merge(service, createTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) +}) diff --git a/packages/client/src/registration/register.ts b/packages/client/src/registration/register.ts new file mode 100644 index 0000000..06e8cda --- /dev/null +++ b/packages/client/src/registration/register.ts @@ -0,0 +1,156 @@ +/** + * User & passkey registration effects + */ +import { + parseCreationOptionsFromJSON, + type CredentialCreationOptionsJSON, +} from '@github/webauthn-json/browser-ponyfill' +import type { NotSupported } from '@passlock/shared/dist/error/error.js' +import { Duplicate, InternalBrowserError } from '@passlock/shared/dist/error/error.js' +import type { OptionsErrors, VerificationErrors } from '@passlock/shared/dist/rpc/registration.js' +import { OptionsReq, RegistrationClient, VerificationReq } from '@passlock/shared/dist/rpc/registration.js' +import type { + Principal, + RegistrationCredential, + UserVerification, + VerifyEmail, +} from '@passlock/shared/dist/schema/schema.js' +import { Context, Effect as E, Layer, flow, pipe } from 'effect' +import { Capabilities } from '../capabilities/capabilities.js' +import { StorageService } from '../storage/storage.js' +import { UserService } from '../user/user.js' + +/* Requests */ + +export type RegistrationRequest = { + email: string + givenName: string + familyName: string + userVerification?: UserVerification + verifyEmail?: VerifyEmail +} + +/* Dependencies */ + +export type CreateCredential = ( + request: CredentialCreationOptions, +) => E.Effect + +export const CreateCredential = Context.GenericTag('@services/Create') + +/* Errors */ + +export type RegistrationErrors = NotSupported | OptionsErrors | VerificationErrors + +/* Service */ + +export type RegistrationService = { + registerPasskey: (request: RegistrationRequest) => E.Effect +} + +export const RegistrationService = Context.GenericTag( + '@services/RegistrationService', +) + +/* Utilities */ + +const fetchOptions = (request: OptionsReq) => { + return E.gen(function* (_) { + yield* _(E.logDebug('Making request')) + + const rpcClient = yield* _(RegistrationClient) + const { publicKey, session } = yield* _(rpcClient.getRegistrationOptions(request)) + + yield* _(E.logDebug('Converting Passlock options to CredentialCreationOptions')) + const options = yield* _(toCreationOptions({ publicKey })) + + return { options, session } + }) +} + +const toCreationOptions = (jsonOptions: CredentialCreationOptionsJSON) => { + return pipe( + E.try(() => parseCreationOptionsFromJSON(jsonOptions)), + E.mapError( + error => + new InternalBrowserError({ + message: 'Browser was unable to create credential creation options', + detail: String(error.error), + }), + ), + ) +} + +const verifyCredential = (request: VerificationReq) => { + return E.gen(function* (_) { + yield* _(E.logDebug('Making request')) + + const rpcClient = yield* _(RegistrationClient) + const { principal } = yield* _(rpcClient.verifyRegistrationCredential(request)) + + return principal + }) +} + +/* Effects */ + +type Dependencies = Capabilities | CreateCredential | StorageService | UserService | RegistrationClient + +export const registerPasskey = ( + request: RegistrationRequest, +): E.Effect => { + const effect = E.gen(function* (_) { + yield* _(E.logInfo('Checking if browser supports Passkeys')) + const capabilities = yield* _(Capabilities) + yield* _(capabilities.passkeySupport) + + yield* _(E.logInfo('Fetching registration options from Passlock')) + const { options, session } = yield* _(fetchOptions(new OptionsReq(request))) + + yield* _(E.logInfo('Building new credential')) + const createCredential = yield* _(CreateCredential) + const credential = yield* _(createCredential(options)) + + yield* _(E.logInfo('Storing credential public key in Passlock')) + const verificationRequest = new VerificationReq({ + ...request, + credential, + session, + }) + + const principal = yield* _(verifyCredential(verificationRequest)) + + const storageService = yield* _(StorageService) + yield* _(storageService.storeToken(principal)) + yield* _(E.logDebug('Storing token in local storage')) + + yield* _(E.logDebug('Defering local token deletion')) + const delayedClearTokenE = pipe( + storageService.clearExpiredToken('passkey'), + E.delay('6 minutes'), + E.fork, + ) + yield* _(delayedClearTokenE) + + return principal + }) + + return E.catchTag(effect, 'InternalBrowserError', e => E.die(e)) +} + +/* Live */ + +/* v8 ignore start */ +export const RegistrationServiceLive = Layer.effect( + RegistrationService, + E.gen(function* (_) { + const context = yield* _( + E.context(), + ) + + return RegistrationService.of({ + registerPasskey: flow(registerPasskey, E.provide(context)), + }) + }), +) +/* v8 ignore stop */ diff --git a/packages/client/src/social/social.fixture.ts b/packages/client/src/social/social.fixture.ts new file mode 100644 index 0000000..4540d01 --- /dev/null +++ b/packages/client/src/social/social.fixture.ts @@ -0,0 +1,51 @@ +import * as Shared from '@passlock/shared/dist/rpc/social.js' +import { SocialClient } from '@passlock/shared/dist/rpc/social.js' +import { Effect as E, Layer as L, Option as O } from 'effect' +import * as Fixtures from '../test/fixtures.js' +import type { AuthenticateOidcReq, RegisterOidcReq } from './social.js' + +export const session = 'session' +export const token = 'token' +export const code = 'code' +export const authType = 'passkey' +export const expireAt = Date.now() + 10000 + +export const registerOidcReq: RegisterOidcReq = { + provider: 'google', + idToken: 'google-token', + nonce: 'nonce', + givenName: 'john', + familyName: 'doe' +} + +export const authOidcReq: AuthenticateOidcReq = { + provider: 'google', + idToken: 'google-token', + nonce: 'nonce' +} + +export const rpcRegisterReq = new Shared.RegisterOidcReq({ + ...registerOidcReq, + givenName: O.fromNullable(registerOidcReq.givenName), + familyName: O.fromNullable(registerOidcReq.familyName) +}) + +export const rpcRegisterRes = new Shared.PrincipalRes({ principal: Fixtures.principal }) + +export const rpcAuthenticateReq = new Shared.AuthOidcReq({ ...authOidcReq }) + +export const rpcAuthenticateRes = new Shared.PrincipalRes({ principal: Fixtures.principal }) + +export const rpcClientTest = L.succeed( + SocialClient, + SocialClient.of({ + registerOidc: () => E.fail(Fixtures.notImplemented), + authenticateOidc: () => E.fail(Fixtures.notImplemented), + }) +) + +export const principal = Fixtures.principal + +export const capabilitiesTest = Fixtures.capabilitiesTest + +export const storageServiceTest = Fixtures.storageServiceTest diff --git a/packages/client/src/social/social.test.ts b/packages/client/src/social/social.test.ts new file mode 100644 index 0000000..e5ccad9 --- /dev/null +++ b/packages/client/src/social/social.test.ts @@ -0,0 +1,193 @@ +import { Duplicate, NotFound } from '@passlock/shared/dist/error/error.js' +import { SocialClient } from '@passlock/shared/dist/rpc/social.js' +import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect' +import { describe, expect, test } from 'vitest' +import { mock } from 'vitest-mock-extended' +import * as Fixture from './social.fixture.js' +import { SocialService, SocialServiceLive } from './social.js' + +describe('registerOidc should', () => { + test('return a valid credential', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(SocialService) + const result = yield* _(service.registerOidc(Fixture.registerOidcReq)) + expect(result).toEqual(Fixture.principal) + }) + + const rpcClientTest = L.effect( + SocialClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.registerOidc.mockReturnValue(E.succeed(Fixture.rpcRegisterRes)) + + return rpcMock + }), + ) + + const service = pipe( + SocialServiceLive, + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('pass the request to the backend', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(SocialService) + yield* _(service.registerOidc(Fixture.registerOidcReq)) + + const rpcClient = yield* _(SocialClient) + expect(rpcClient.registerOidc).toHaveBeenCalledWith(Fixture.rpcRegisterReq) + }) + + const rpcClientTest = L.effect( + SocialClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.registerOidc.mockReturnValue(E.succeed(Fixture.rpcRegisterRes)) + + return rpcMock + }), + ) + + const service = pipe( + SocialServiceLive, + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('return an error if we try to register an existing user', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(SocialService) + + const defect = yield* _(service.registerOidc(Fixture.registerOidcReq), E.flip) + + expect(defect).toBeInstanceOf(Duplicate) + }) + + const rpcClientTest = L.effect( + SocialClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.registerOidc.mockReturnValue(E.fail(new Duplicate({ message: "Duplicate user" }))) + + return rpcMock + }), + ) + + const service = pipe( + SocialServiceLive, + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) +}) + +describe('authenticateIodc should', () => { + test('return a valid credential', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(SocialService) + const result = yield* _(service.authenticateOidc(Fixture.authOidcReq)) + expect(result).toEqual(Fixture.principal) + }) + + const rpcClientTest = L.effect( + SocialClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.authenticateOidc.mockReturnValue(E.succeed(Fixture.rpcRegisterRes)) + + return rpcMock + }), + ) + + const service = pipe( + SocialServiceLive, + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('pass the request to the backend', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(SocialService) + yield* _(service.authenticateOidc(Fixture.authOidcReq)) + + const rpcClient = yield* _(SocialClient) + expect(rpcClient.authenticateOidc).toHaveBeenCalledWith(Fixture.rpcAuthenticateReq) + }) + + const rpcClientTest = L.effect( + SocialClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.authenticateOidc.mockReturnValue(E.succeed(Fixture.rpcAuthenticateRes)) + + return rpcMock + }), + ) + + const service = pipe( + SocialServiceLive, + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('return an error if we try to authenticate a non-existing user', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(SocialService) + + const defect = yield* _(service.authenticateOidc(Fixture.authOidcReq), E.flip) + + expect(defect).toBeInstanceOf(NotFound) + }) + + const rpcClientTest = L.effect( + SocialClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.authenticateOidc.mockReturnValue(E.fail(new NotFound({ message: "User not found" }))) + + return rpcMock + }), + ) + + const service = pipe( + SocialServiceLive, + L.provide(rpcClientTest), + ) + + const layers = Layer.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) +}) diff --git a/packages/client/src/social/social.ts b/packages/client/src/social/social.ts new file mode 100644 index 0000000..3ac5d92 --- /dev/null +++ b/packages/client/src/social/social.ts @@ -0,0 +1,107 @@ +/** + * Passkey authentication effects + */ +import { + type BadRequest, + type NotSupported +} from '@passlock/shared/dist/error/error.js' +import * as Shared from '@passlock/shared/dist/rpc/social.js' +import { SocialClient } from '@passlock/shared/dist/rpc/social.js' +import type { + Principal +} from '@passlock/shared/dist/schema/schema.js' +import { Context, Effect as E, Layer, Option as O, flow } from 'effect' + +/* Requests */ + +export type Provider = 'apple' | 'google' + +export type RegisterOidcReq = { + provider: Provider + idToken: string + nonce: string + givenName?: string + familyName?: string +} + +export type AuthenticateOidcReq = { + provider: Provider + idToken: string + nonce: string +} + +/* Errors */ + +export type RegistrationErrors = NotSupported | BadRequest | Shared.RegisterOidcErrors + +export type AuthenticationErrors = NotSupported | BadRequest | Shared.AuthOidcErrors + +/* Service */ + +export type SocialService = { + registerOidc: (data: RegisterOidcReq) => E.Effect + authenticateOidc: (data: AuthenticateOidcReq) => E.Effect +} + +export const SocialService = Context.GenericTag( + '@services/SocialService', +) + +/* Effects */ + +type Dependencies = SocialClient + +export const registerOidc = ( + request: RegisterOidcReq, +): E.Effect => { + return E.gen(function* (_) { + yield* _(E.logInfo('Registering social account')) + + const rpcClient = yield* _(SocialClient) + + const rpcRequest = new Shared.RegisterOidcReq({ + ...request, + givenName: O.fromNullable(request.givenName), + familyName: O.fromNullable(request.familyName), + }) + + const { principal } = yield* _( + rpcClient.registerOidc(rpcRequest) + ) + + return principal + }) +} + +export const authenticateOidc = ( + request: AuthenticateOidcReq, +): E.Effect => { + return E.gen(function* (_) { + yield* _(E.logInfo('Authenticating with social account')) + + const rpcClient = yield* _(SocialClient) + const rpcRequest = new Shared.AuthOidcReq(request) + + const { principal } = yield* _( + rpcClient.authenticateOidc(rpcRequest) + ) + + return principal + }) +} + +/* Live */ + +/* v8 ignore start */ +export const SocialServiceLive = Layer.effect( + SocialService, + E.gen(function* (_) { + const context = yield* _(E.context()) + + return SocialService.of({ + registerOidc: flow(registerOidc, E.provide(context)), + authenticateOidc: flow(authenticateOidc, E.provide(context)) + }) + }), +) +/* v8 ignore stop */ diff --git a/packages/client/src/storage/storage.fixture.ts b/packages/client/src/storage/storage.fixture.ts new file mode 100644 index 0000000..eaae895 --- /dev/null +++ b/packages/client/src/storage/storage.fixture.ts @@ -0,0 +1,16 @@ +import { Effect as E, Layer, pipe } from 'effect' +import { mock } from 'vitest-mock-extended' +import { Storage, StorageServiceLive } from './storage.js' + +const storageTest = Layer.effect( + Storage, + E.sync(() => mock()), +) + +export const testLayers = (storage: Layer.Layer = storageTest) => { + const storageService = pipe(StorageServiceLive, Layer.provide(storage)) + + return Layer.merge(storage, storageService) +} + +export { principal } from '../test/fixtures.js' diff --git a/packages/client/src/storage/storage.test.ts b/packages/client/src/storage/storage.test.ts new file mode 100644 index 0000000..b5e338c --- /dev/null +++ b/packages/client/src/storage/storage.test.ts @@ -0,0 +1,196 @@ +import { Effect as E, Layer, LogLevel, Logger, identity, pipe } from 'effect' +import { describe, expect, test } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { principal, testLayers } from './storage.fixture.js' +import { Storage, StorageService, clearExpiredToken, clearToken, getToken } from './storage.js' + +// eslint chokes on expect(storage.setItem) etc +/* eslint @typescript-eslint/unbound-method: 0 */ + +describe('storeToken should', () => { + test('set the token in local storage', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(StorageService) + yield* _(service.storeToken(principal)) + + const storage = yield* _(Storage) + expect(storage.setItem).toHaveBeenCalled() + }) + + const effect = pipe( + E.provide(assertions, testLayers()), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + E.runSync(effect) + }) + + test('with the key passlock:passkey:token', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(StorageService) + yield* _(service.storeToken(principal)) + + const storage = yield* _(Storage) + expect(storage.setItem).toHaveBeenCalledWith('passlock:passkey:token', expect.any(String)) + }) + + const effect = pipe( + E.provide(assertions, testLayers()), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + E.runSync(effect) + }) + + test('with the value token:expiry', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(StorageService) + yield* _(service.storeToken(principal)) + + const storage = yield* _(Storage) + const token = principal.token + const expiry = principal.expireAt.getTime() + expect(storage.setItem).toHaveBeenCalledWith('passlock:passkey:token', `${token}:${expiry}`) + }) + + const effect = pipe( + E.provide(assertions, testLayers()), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + E.runSync(effect) + }) +}) + +describe('getToken should', () => { + test('get the token from local storage', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(StorageService) + yield* _(service.getToken('passkey')) + + const storage = yield* _(Storage) + expect(storage.getItem).toHaveBeenCalled() + expect(storage.getItem).toHaveBeenCalledWith('passlock:passkey:token') + }) + + const storageTest = Layer.effect( + Storage, + E.sync(() => { + const mockStorage = mock() + const expiry = Date.now() + 1000 + mockStorage.getItem.mockReturnValue(`token:${expiry}`) + return mockStorage + }), + ) + + const effect = pipe( + E.provide(assertions, testLayers(storageTest)), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + E.runSync(effect) + }) + + test('filter out expired tokens', () => { + const assertions = pipe( + getToken('passkey'), + E.match({ + onSuccess: identity, + onFailure: () => undefined, + }), + E.flatMap(result => + E.sync(() => { + expect(result).toBeUndefined() + }), + ), + ) + + const storageTest = Layer.effect( + Storage, + E.sync(() => { + const mockStorage = mock() + const expiry = Date.now() - 1000 + mockStorage.getItem.mockReturnValue(`token:${expiry}`) + return mockStorage + }), + ) + + const effect = pipe( + E.provide(assertions, testLayers(storageTest)), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + E.runSync(effect) + }) +}) + +describe('clearToken should', () => { + test('clear the token in local storage', () => { + const assertions = E.gen(function* (_) { + const storage = yield* _(Storage) + yield* _(clearToken('passkey')) + expect(storage.removeItem).toHaveBeenCalledWith('passlock:passkey:token') + }) + + const effect = pipe( + E.provide(assertions, testLayers()), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + E.runSync(effect) + }) +}) + +describe('clearExpiredToken should', () => { + test('clear an expired token from local storage', () => { + const assertions = E.gen(function* (_) { + const storage = yield* _(Storage) + yield* _(clearExpiredToken('passkey')) + expect(storage.getItem).toHaveBeenCalledWith('passlock:passkey:token') + expect(storage.removeItem).toHaveBeenCalledWith('passlock:passkey:token') + }) + + const storageTest = Layer.effect( + Storage, + E.sync(() => { + const mockStorage = mock() + const expiry = Date.now() - 1000 + mockStorage.getItem.mockReturnValue(`token:${expiry}`) + return mockStorage + }), + ) + + const effect = pipe( + E.provide(assertions, testLayers(storageTest)), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + E.runSync(effect) + }) + + test('leave a live token in local storage', () => { + const assertions = E.gen(function* (_) { + const storage = yield* _(Storage) + yield* _(clearExpiredToken('passkey')) + expect(storage.getItem).toHaveBeenCalledWith('passlock:passkey:token') + expect(storage.removeItem).not.toHaveBeenCalled() + }) + + const storageTest = Layer.effect( + Storage, + E.sync(() => { + const mockStorage = mock() + const expiry = Date.now() + 1000 + mockStorage.getItem.mockReturnValue(`token:${expiry}`) + return mockStorage + }), + ) + + const effect = pipe( + E.provide(assertions, testLayers(storageTest)), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + E.runSync(effect) + }) +}) diff --git a/packages/client/src/storage/storage.ts b/packages/client/src/storage/storage.ts new file mode 100644 index 0000000..fede0e3 --- /dev/null +++ b/packages/client/src/storage/storage.ts @@ -0,0 +1,167 @@ +/** + * Wrapper around local storage that allows us to store + * authentication tokens in local storage for a short period. + */ +import type { Principal } from '@passlock/shared/dist/schema/schema.js' +import { Context, Effect as E, Layer, Option as O, flow, pipe } from 'effect' +import type { NoSuchElementException } from 'effect/Cause' + +/* Requests */ + +export type AuthType = 'email' | 'passkey' | 'apple' | 'google' + +export type StoredToken = { + token: string + authType: AuthType + expireAt: number +} + +/* Service */ + +export type StorageService = { + storeToken: (principal: Principal) => E.Effect + getToken: (authType: AuthType) => E.Effect + clearToken: (authType: AuthType) => E.Effect + clearExpiredToken: (authType: AuthType) => E.Effect + clearExpiredTokens: E.Effect +} + +/* Utilities */ + +export const StorageService = Context.GenericTag('@services/StorageService') + +// inject window.localStorage to make testing easier +export const Storage = Context.GenericTag('@services/Storage') + +export const buildKey = (authType: AuthType) => `passlock:${authType}:token` + +// principal => token:expireAt +export const compressToken = (principal: Principal): string => { + const expireAt = principal.expireAt.getTime() + const token = principal.token + return `${token}:${expireAt}` +} + +// token:expireAt => { authType, token, expireAt } +export const expandToken = + (authType: AuthType) => + (s: string): O.Option => { + const tokens = s.split(':') + if (tokens.length !== 2) return O.none() + + const [token, expireAtString] = tokens + const parse = O.liftThrowable(Number.parseInt) + const expireAt = parse(expireAtString) + + return O.map(expireAt, expireAt => ({ authType, token, expireAt })) + } + +/* Effects */ + +/** + * Store compressed token in local storage + * @param principal + * @returns + */ +export const storeToken = (principal: Principal): E.Effect => { + return E.gen(function* (_) { + const localStorage = yield* _(Storage) + + const storeEffect = E.try(() => { + const compressed = compressToken(principal) + const key = buildKey(principal.authStatement.authType) + localStorage.setItem(key, compressed) + }).pipe(E.orElse(() => E.void)) // We dont care if it fails + + return yield* _(storeEffect) + }) +} + +/** + * Get stored token from local storage + * @param authType + * @returns + */ +export const getToken = ( + authType: AuthType, +): E.Effect => { + return E.gen(function* (_) { + const localStorage = yield* _(Storage) + + const getEffect = pipe( + O.some(buildKey(authType)), + O.flatMap(key => pipe(localStorage.getItem(key), O.fromNullable)), + O.flatMap(expandToken(authType)), + O.filter(({ expireAt: expireAt }) => expireAt > Date.now()), + ) + + return yield* _(getEffect) + }) +} + +/** + * Remove token from local storage + * @param authType + * @returns + */ +export const clearToken = (authType: AuthType): E.Effect => { + return E.gen(function* (_) { + const localStorage = yield* _(Storage) + localStorage.removeItem(buildKey(authType)) + }) +} + +/** + * Only clear if now > token.expireAt + * @param authType + * @param defer + * @returns + */ +export const clearExpiredToken = (authType: AuthType): E.Effect => { + const key = buildKey(authType) + + const effect = E.gen(function* (_) { + const storage = yield* _(Storage) + const item = yield* _(O.fromNullable(storage.getItem(key))) + const token = yield* _(expandToken(authType)(item)) + + if (token.expireAt < Date.now()) { + storage.removeItem(key) + } + }) + + // we don't care if it fails + return pipe( + effect, + E.match({ + onSuccess: () => E.void, + onFailure: () => E.void, + }), + ) +} + +export const clearExpiredTokens: E.Effect = E.all([ + clearExpiredToken('passkey'), + clearExpiredToken('email'), + clearExpiredToken('google'), + clearExpiredToken('apple'), +]) + +/* Live */ + +/* v8 ignore start */ +export const StorageServiceLive = Layer.effect( + StorageService, + E.gen(function* (_) { + const context = yield* _(E.context()) + + return { + storeToken: flow(storeToken, E.provide(context)), + getToken: flow(getToken, E.provide(context)), + clearToken: flow(clearToken, E.provide(context)), + clearExpiredToken: flow(clearExpiredToken, E.provide(context)), + clearExpiredTokens: pipe(clearExpiredTokens, E.provide(context)), + } + }), +) +/* v8 ignore stop */ diff --git a/packages/client/src/test/fixtures.ts b/packages/client/src/test/fixtures.ts new file mode 100644 index 0000000..d5f6dff --- /dev/null +++ b/packages/client/src/test/fixtures.ts @@ -0,0 +1,53 @@ +import { BadRequest } from '@passlock/shared/dist/error/error.js' +import type { Principal } from '@passlock/shared/dist/schema/schema.js' +import { Effect as E, Layer as L } from 'effect' +import { Capabilities } from '../capabilities/capabilities.js' +import { StorageService, type StoredToken } from '../storage/storage.js' + +export const session = 'session' +export const token = 'token' +export const code = 'code' +export const authType = 'passkey' +export const expireAt = Date.now() + 10000 + +export const principal: Principal = { + token: 'token', + user: { + id: '1', + email: 'john.doe@gmail.com', + givenName: 'john', + familyName: 'doe', + emailVerified: false, + }, + authStatement: { + authType: 'passkey', + userVerified: false, + authTimestamp: new Date(0), + }, + expireAt: new Date(0), +} + +export const capabilitiesTest = L.succeed( + Capabilities, + Capabilities.of({ + passkeySupport: E.void, + isPasskeySupport: E.succeed(true), + autofillSupport: E.void, + isAutofillSupport: E.succeed(true), + }), +) + +export const storedToken: StoredToken = { token, authType, expireAt } + +export const storageServiceTest = L.succeed( + StorageService, + StorageService.of({ + storeToken: () => E.void, + getToken: () => E.succeed(storedToken), + clearToken: () => E.void, + clearExpiredToken: () => E.void, + clearExpiredTokens: E.void, + }), +) + +export const notImplemented = new BadRequest({ message: 'Not implemented' }) \ No newline at end of file diff --git a/packages/client/src/user/user.fixture.ts b/packages/client/src/user/user.fixture.ts new file mode 100644 index 0000000..0831718 --- /dev/null +++ b/packages/client/src/user/user.fixture.ts @@ -0,0 +1,21 @@ +import { IsExistingUserReq, IsExistingUserRes, ResendEmailReq, ResendEmailRes, UserClient, VerifyEmailRes } from '@passlock/shared/dist/rpc/user.js' +import { Effect as E, Layer as L } from 'effect' +import * as Fixtures from '../test/fixtures.js' +import type { ResendEmail } from './user.js' + +export const email = 'jdoe@gmail.com' +export const isRegisteredReq = new IsExistingUserReq({ email }) +export const isRegisteredRes = new IsExistingUserRes({ existingUser: false }) +export const verifyEmailRes = new VerifyEmailRes({ principal: Fixtures.principal }) +export const resendEmailReq: ResendEmail = { userId: '123', method: 'code' } +export const rpcResendEmailReq = new ResendEmailReq({ userId: '123', verifyEmail: { method: 'code' }}) +export const rpcResendEmailRes = new ResendEmailRes({ }) + +export const rpcClientTest = L.succeed( + UserClient, + UserClient.of({ + isExistingUser: () => E.succeed({ existingUser: true }), + verifyEmail: () => E.succeed(verifyEmailRes), + resendVerificationEmail: () => E.fail(Fixtures.notImplemented), + }), +) \ No newline at end of file diff --git a/packages/client/src/user/user.test.ts b/packages/client/src/user/user.test.ts new file mode 100644 index 0000000..f18aea7 --- /dev/null +++ b/packages/client/src/user/user.test.ts @@ -0,0 +1,86 @@ +import { UserClient } from '@passlock/shared/dist/rpc/user.js' +import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect' +import { describe, expect, test } from 'vitest' +import { mock } from 'vitest-mock-extended' +import * as Fixture from './user.fixture.js' +import { UserService, UserServiceLive } from './user.js' + +describe('isExistingUser should', () => { + test('return true when the user already has a passkey', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(UserService) + const result = yield* _(service.isExistingUser({ email: Fixture.email })) + + expect(result).toBe(true) + }) + + const service = pipe(UserServiceLive, L.provide(Fixture.rpcClientTest)) + + const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) + + test('send the email to the backend', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(UserService) + const result = yield* _(service.isExistingUser({ email: Fixture.email })) + + expect(result).toBe(false) + const rpcClient = yield* _(UserClient) + expect(rpcClient.isExistingUser).toBeCalledWith(Fixture.isRegisteredReq) + }) + + const rpcClientTest = Layer.effect( + UserClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.isExistingUser.mockReturnValue(E.succeed(Fixture.isRegisteredRes)) + + return rpcMock + }), + ) + + const service = pipe(UserServiceLive, L.provide(rpcClientTest)) + + const layers = L.merge(service, rpcClientTest) + const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None)) + + return E.runPromise(effect) + }) +}) + +describe('resendVerificationEmail should', () => { + test('forward the request to the backend', async () => { + const assertions = E.gen(function* (_) { + const service = yield* _(UserService) + yield* _(service.resendVerificationEmail(Fixture.resendEmailReq)) + + const rpcClient = yield* _(UserClient) + expect(rpcClient.resendVerificationEmail).toBeCalledWith(Fixture.rpcResendEmailReq) + }) + + const rpcClientTest = Layer.effect( + UserClient, + E.sync(() => { + const rpcMock = mock() + + rpcMock.resendVerificationEmail.mockReturnValue(E.succeed(Fixture.rpcResendEmailRes)) + + return rpcMock + }), + ) + + const service = pipe(UserServiceLive, L.provide(rpcClientTest)) + + const layers = L.merge(service, rpcClientTest) + + const effect = pipe( + E.provide(assertions, layers), + Logger.withMinimumLogLevel(LogLevel.None) + ) + + return E.runPromise(effect) + }) +}) diff --git a/packages/client/src/user/user.ts b/packages/client/src/user/user.ts new file mode 100644 index 0000000..67ed553 --- /dev/null +++ b/packages/client/src/user/user.ts @@ -0,0 +1,67 @@ +/** + * Check for an existing user + */ +import type { BadRequest, Disabled, NotFound } from '@passlock/shared/dist/error/error.js' +import { IsExistingUserReq, ResendEmailReq, UserClient } from '@passlock/shared/dist/rpc/user.js' +import type { VerifyEmail } from '@passlock/shared/dist/schema/schema.js' +import { Context, Effect as E, Layer, flow } from 'effect' + +/* Requests */ + +export type Email = { email: string } +export type ResendEmail = VerifyEmail & { userId: string } + +/* Errors */ + +export type ResendEmailErrors = BadRequest | NotFound | Disabled + +/* Service */ + +export type UserService = { + isExistingUser: (request: Email) => E.Effect + resendVerificationEmail: (request: ResendEmail) => E.Effect +} + +export const UserService = Context.GenericTag('@services/UserService') + +/* Effects */ + +type Dependencies = UserClient + +export const isExistingUser = (request: Email): E.Effect => { + return E.gen(function* (_) { + yield* _(E.logInfo('Checking registration status')) + const rpcClient = yield* _(UserClient) + + yield* _(E.logDebug('Making RPC request')) + const { existingUser } = yield* _(rpcClient.isExistingUser(new IsExistingUserReq(request))) + + return existingUser + }) +} + +export const resendVerificationEmail = (request: ResendEmail): E.Effect => { + return E.gen(function* (_) { + yield* _(E.logInfo('Resending verification email')) + const rpcClient = yield* _(UserClient) + + yield* _(E.logDebug('Making RPC request')) + const { userId, ...verifyEmail } = request + yield* _(rpcClient.resendVerificationEmail(new ResendEmailReq({ userId, verifyEmail }))) + }) +} + +/* Live */ + +/* v8 ignore start */ +export const UserServiceLive = Layer.effect( + UserService, + E.gen(function* (_) { + const context = yield* _(E.context()) + return UserService.of({ + isExistingUser: flow(isExistingUser, E.provide(context)), + resendVerificationEmail: flow(resendVerificationEmail, E.provide(context)) + }) + }), +) +/* v8 ignore stop */ diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..fd16bd4 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "@tsconfig/node18/tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "exactOptionalPropertyTypes": true, + "lib": ["es2023", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "./dist", + "removeComments": true, + "rootDir": "./src", + "sourceMap": true, + "verbatimModuleSyntax": true + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.json" + ], + "exclude": [ + "./node_modules/**" + ] +} \ No newline at end of file diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts new file mode 100644 index 0000000..85b4a1a --- /dev/null +++ b/packages/client/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'jsdom', + coverage: { + provider: 'v8', + include: ['src/**'], + reporter: ['text', ['html', { subdir: 'html' }]], + exclude: [ + 'src/index.ts', // re-exports + 'src/effect.ts', // re-exports + 'src/config.ts', // no real logic here + 'src/capabilities/*.ts', // wrapper around browser api + 'src/**/*.fixture.ts', // test fixtures + 'src/test/fixtures.ts', // test fixtures + ], + }, + }, + server: { + port: 5174 + } +}) diff --git a/packages/node/.eslintignore b/packages/node/.eslintignore new file mode 100644 index 0000000..4e4b960 --- /dev/null +++ b/packages/node/.eslintignore @@ -0,0 +1,2 @@ +/* +!/src \ No newline at end of file diff --git a/packages/node/.eslintrc.cjs b/packages/node/.eslintrc.cjs new file mode 100644 index 0000000..701ab02 --- /dev/null +++ b/packages/node/.eslintrc.cjs @@ -0,0 +1,88 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true + }, + extends: [ + 'eslint:recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/strict-type-checked', + 'prettier' + ], + overrides: [], + parser: "@typescript-eslint/parser", + parserOptions: { + project: './tsconfig.json' + }, + plugins: [ + "@typescript-eslint", + "import" + ], + root: true, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "ignoreRestSiblings": true, + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/consistent-type-definitions": [ + "warn", + "type" + ], + "@typescript-eslint/consistent-type-imports": [ + "error" + ], + "sort-imports": [ + "warn", + { + ignoreCase: false, + ignoreDeclarationSort: true, // don"t want to sort import lines, use eslint-plugin-import instead + ignoreMemberSort: false, + memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], + allowSeparatedGroups: true, + }, + ], + + "import/no-unresolved": "error", + "import/newline-after-import": [ + "warn", + { + "count": 1 + } + ], + 'import/order': [ + 'warn', + { + groups: [ + 'builtin', // Built-in imports (come from NodeJS native) go first + 'external', // <- External imports + 'internal', // <- Absolute imports + ['sibling', 'parent'], // <- Relative imports, the sibling and parent types they can be mingled together + 'index', // <- index imports + 'unknown', // <- unknown + ], + 'newlines-between': 'ignore', + alphabetize: { + /* sort in ascending order. Options: ["ignore", "asc", "desc"] */ + order: 'asc', + /* ignore case. Options: [true, false] */ + caseInsensitive: true, + }, + }, + ] + }, + settings: { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"] + }, + "import/resolver": { + "typescript": true + } + } +} \ No newline at end of file diff --git a/packages/node/.gitignore b/packages/node/.gitignore new file mode 100644 index 0000000..b736c88 --- /dev/null +++ b/packages/node/.gitignore @@ -0,0 +1,34 @@ +# dependencies +node_modules + +# production +build +dist +dist-ssr +*.local + +# logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# typescript +tsconfig.tsbuildinfo + +# editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# coverage +coverage diff --git a/packages/node/.prettierrc.json b/packages/node/.prettierrc.json new file mode 100644 index 0000000..5f7b9ac --- /dev/null +++ b/packages/node/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": false, + "singleQuote": true, + "arrowParens": "avoid", + "printWidth": 100, + "quoteProps": "consistent", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "ignore", + "bracketSameLine": true, + "useTabs": false +} diff --git a/packages/node/LICENSE b/packages/node/LICENSE new file mode 100644 index 0000000..0b9c381 --- /dev/null +++ b/packages/node/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Passlock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/node/package.json b/packages/node/package.json new file mode 100644 index 0000000..cd8a52f --- /dev/null +++ b/packages/node/package.json @@ -0,0 +1,73 @@ +{ + "name": "@passlock/node", + "type": "module", + "version": "0.9.19", + "description": "Server side passkey library for node/express", + "keywords": [ + "passkey", + "passkeys", + "webauthn", + "node", + "express" + ], + "author": { + "name": "Toby Hobson", + "email": "toby@passlock.dev" + }, + "license": "MIT", + "homepage": "https://passlock.dev", + "repository": "github.com/passlock-dev/node", + "bugs": { + "url": "https://github.com/passlock-dev/node/issues", + "email": "team@passlock.dev" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "src", + "dist" + ], + "scripts": { + "clean": "tsc --build --clean", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest dev", + "test:ui": "vitest --coverage.enabled=true --ui", + "test:coverage": "vitest run --coverage", + "build": "tsc --build", + "build:clean": "pnpm run clean && pnpm run build", + "build:watch": "tsc --build --watch", + "format": "prettier --write \"src/**/*.+(js|ts|json)\"", + "lint": "eslint --ext .ts src", + "lint:fix": "pnpm run lint --fix", + "prepublishOnly": "pnpm run clean && pnpm run build", + "ncu": "ncu --peer -x @effect/* -x effect", + "ncu:save": "ncu --peer -x @effect/* -x effect -u" + }, + "dependencies": { + "@passlock/shared": "workspace:^", + "effect": "^3.4.8" + }, + "devDependencies": { + "@tsconfig/node18": "^18.2.4", + "@types/node": "^20.14.10", + "@typescript-eslint/eslint-plugin": "^7.15.0", + "@typescript-eslint/parser": "^7.15.0", + "@vitest/coverage-v8": "^2.0.3", + "@vitest/ui": "^2.0.3", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "prettier": "^3.3.2", + "rimraf": "^5.0.8", + "tsx": "^4.16.2", + "typescript": "^5.5.3", + "vite": "^5.3.3", + "vitest": "^2.0.3" + } +} diff --git a/packages/node/src/config/config.ts b/packages/node/src/config/config.ts new file mode 100644 index 0000000..792b45f --- /dev/null +++ b/packages/node/src/config/config.ts @@ -0,0 +1,10 @@ +import { Context } from 'effect' + +export class Config extends Context.Tag('Config')< + Config, + { + readonly tenancyId: string + readonly apiKey: string + readonly endpoint?: string + } +>() {} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts new file mode 100644 index 0000000..54140e9 --- /dev/null +++ b/packages/node/src/index.ts @@ -0,0 +1,153 @@ +import { + ErrorCode, + Forbidden, + InternalServerError, + NotFound, + Unauthorized, +} from '@passlock/shared/dist/error/error.js' +import { Effect as E, Layer as L, Runtime, Scope, pipe } from 'effect' +import { Config } from './config/config.js' +import { + PrincipalService, + PrincipalServiceLive, + StreamResponseLive, + type PrincipalRequest, +} from './principal/principal.js' + +export type { PrincipalRequest } from './principal/principal.js' + +export { ErrorCode } from '@passlock/shared/dist/error/error.js' + +export class PasslockError extends Error { + readonly _tag = 'PasslockError' + readonly code: ErrorCode + + constructor(message: string, code: ErrorCode) { + super(message) + this.code = code + } + + static readonly isError = (error: unknown): error is PasslockError => { + return ( + typeof error === 'object' && + error !== null && + '_tag' in error && + error['_tag'] === 'PasslockError' + ) + } +} + +type PasslockErrors = NotFound | Unauthorized | Forbidden | InternalServerError + +const hasMessage = (defect: unknown): defect is { message: string } => { + return ( + typeof defect === 'object' && + defect !== null && + 'message' in defect && + typeof defect['message'] === 'string' + ) +} + +const transformErrors = ( + effect: E.Effect, +): E.Effect => { + const withErrorHandling = E.catchTags(effect, { + NotFound: e => E.succeed(new PasslockError(e.message, ErrorCode.NotFound)), + Unauthorized: e => E.succeed(new PasslockError(e.message, ErrorCode.Unauthorized)), + Forbidden: e => E.succeed(new PasslockError(e.message, ErrorCode.Forbidden)), + InternalServerError: e => + E.succeed(new PasslockError(e.message, ErrorCode.InternalServerError)), + }) + + const sandboxed = E.sandbox(withErrorHandling) + + const withSandboxing = E.catchTags(sandboxed, { + Die: ({ defect }) => { + return hasMessage(defect) + ? E.succeed(new PasslockError(defect.message, ErrorCode.InternalServerError)) + : E.succeed(new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError)) + }, + + Interrupt: () => { + return E.succeed(new PasslockError('Operation aborted', ErrorCode.InternalBrowserError)) + }, + + Sequential: errors => { + console.error(errors) + return E.succeed( + new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError), + ) + }, + + Parallel: errors => { + console.error(errors) + return E.succeed( + new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError), + ) + }, + }) + + return E.unsandbox(withSandboxing) +} + +type Requirements = PrincipalService + +export class PasslockUnsafe { + private readonly runtime: Runtime.Runtime + + constructor(config: { tenancyId: string; apiKey: string; endpoint?: string }) { + const configLive = L.succeed(Config, Config.of(config)) + const allLayers = pipe( + PrincipalServiceLive, + L.provide(configLive), + L.provide(StreamResponseLive), + ) + const scope = E.runSync(Scope.make()) + this.runtime = E.runSync(L.toRuntime(allLayers).pipe(Scope.extend(scope))) + } + + private readonly runPromise = ( + effect: E.Effect, + ) => { + return pipe( + transformErrors(effect), + E.flatMap(result => (PasslockError.isError(result) ? E.fail(result) : E.succeed(result))), + effect => Runtime.runPromise(this.runtime)(effect), + ) + } + + fetchPrincipal = (request: PrincipalRequest) => + pipe( + PrincipalService, + E.flatMap(service => service.fetchPrincipal(request)), + effect => this.runPromise(effect), + ) +} + +export class Passlock { + private readonly runtime: Runtime.Runtime + + constructor(config: { tenancyId: string; apiKey: string; endpoint?: string }) { + const configLive = L.succeed(Config, Config.of(config)) + const allLayers = pipe( + PrincipalServiceLive, + L.provide(configLive), + L.provide(StreamResponseLive), + ) + const scope = E.runSync(Scope.make()) + this.runtime = E.runSync(L.toRuntime(allLayers).pipe(Scope.extend(scope))) + } + + private readonly runPromise = ( + effect: E.Effect, + ) => { + return pipe(transformErrors(effect), effect => Runtime.runPromise(this.runtime)(effect)) + } + + fetchPrincipal = (request: PrincipalRequest) => + pipe( + PrincipalService, + E.flatMap(service => service.fetchPrincipal(request)), + effect => this.runPromise(effect), + ) +} diff --git a/packages/node/src/principal/principal.fixture.ts b/packages/node/src/principal/principal.fixture.ts new file mode 100644 index 0000000..c057fb6 --- /dev/null +++ b/packages/node/src/principal/principal.fixture.ts @@ -0,0 +1,80 @@ +import type { Principal } from '@passlock/shared/dist/schema/schema.js' +import { Context, Effect as E, Layer as L, LogLevel, Logger, Ref, Stream, pipe } from 'effect' +import type { RequestOptions } from 'https' +import { Config } from '../config/config.js' +import { + PrincipalServiceLive, + StreamResponse, + buildError, + type PrincipalService, +} from './principal.js' + +export const principal: Principal = { + token: 'token', + user: { + id: '1', + email: 'john.doe@gmail.com', + givenName: 'john', + familyName: 'doe', + emailVerified: false, + }, + authStatement: { + authType: 'email', + userVerified: false, + authTimestamp: new Date(0), + }, + expireAt: new Date(0), +} + +export const tenancyId = 'tenancyId' +export const apiKey = 'apiKey' + +export const configTest = L.succeed(Config, Config.of({ tenancyId, apiKey })) + +export class State extends Context.Tag('State')>() {} + +export const buildEffect = ( + assertions: E.Effect, +): E.Effect => { + const responseStreamTest = L.effect( + StreamResponse, + E.gen(function* (_) { + const ref = yield* _(State) + const buff = Buffer.from(JSON.stringify(principal)) + return options => + pipe(Stream.fromEffect(Ref.set(ref, options)), Stream.zipRight(Stream.make(buff))) + }), + ) + + const service = pipe(PrincipalServiceLive, L.provide(responseStreamTest), L.provide(configTest)) + + const args = L.effect(State, Ref.make(undefined)) + + const effect = pipe( + E.provide(assertions, service), + E.provide(args), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + return effect +} + +export const buildErrorEffect = + (statusCode: number) => + (assertions: E.Effect): E.Effect => { + const responseStreamTest = L.succeed(StreamResponse, () => + Stream.fail(buildError({ statusCode })), + ) + + const service = pipe(PrincipalServiceLive, L.provide(responseStreamTest), L.provide(configTest)) + + const args = L.effect(State, Ref.make(undefined)) + + const effect = pipe( + E.provide(assertions, service), + E.provide(args), + Logger.withMinimumLogLevel(LogLevel.None), + ) + + return effect + } diff --git a/packages/node/src/principal/principal.test.ts b/packages/node/src/principal/principal.test.ts new file mode 100644 index 0000000..d77f662 --- /dev/null +++ b/packages/node/src/principal/principal.test.ts @@ -0,0 +1,111 @@ +import { + Forbidden, + InternalServerError, + NotFound, + Unauthorized, +} from '@passlock/shared/dist/error/error.js' +import { Effect as E, Effect, Ref } from 'effect' +import { describe, expect, test } from 'vitest' +import * as Fixture from './principal.fixture.js' +import { PrincipalService } from './principal.js' + +describe('fetchPrincipal should', () => { + test('return a valid principal', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(PrincipalService) + const result = yield* _(service.fetchPrincipal({ token: 'token' })) + + expect(result).toEqual(Fixture.principal) + }) + + const effect = Fixture.buildEffect(assertions) + + return E.runPromise(effect) + }) + + test('call the correct url', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(PrincipalService) + yield* _(service.fetchPrincipal({ token: 'myToken' })) + + const state = yield* _(Fixture.State) + const args = yield* _(Ref.get(state)) + + expect(args?.hostname).toEqual('api.passlock.dev') + expect(args?.method).toEqual('GET') + expect(args?.path).toEqual(`/${Fixture.tenancyId}/token/myToken`) + }) + + const effect = Fixture.buildEffect(assertions) + + return E.runPromise(effect) + }) + + test('pass the api key as a header', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(PrincipalService) + yield* _(service.fetchPrincipal({ token: 'myToken' })) + + const state = yield* _(Fixture.State) + const args = yield* _(Ref.get(state)) + + expect(args?.headers?.['Authorization']).toEqual(`Bearer ${Fixture.apiKey}`) + }) + + const effect = Fixture.buildEffect(assertions) + + return E.runPromise(effect) + }) + + test('propagate a 401 error', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(PrincipalService) + const result = service.fetchPrincipal({ token: 'myToken' }) + const error = yield* _(Effect.flip(result)) + expect(error).toBeInstanceOf(Unauthorized) + }) + + const effect = Fixture.buildErrorEffect(401)(assertions) + + return E.runPromise(effect) + }) + + test('propagate a 403 error', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(PrincipalService) + const result = service.fetchPrincipal({ token: 'myToken' }) + const error = yield* _(Effect.flip(result)) + expect(error).toBeInstanceOf(Forbidden) + }) + + const effect = Fixture.buildErrorEffect(403)(assertions) + + return E.runPromise(effect) + }) + + test('propagate a 404 error', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(PrincipalService) + const result = service.fetchPrincipal({ token: 'myToken' }) + const error = yield* _(Effect.flip(result)) + expect(error).toBeInstanceOf(NotFound) + }) + + const effect = Fixture.buildErrorEffect(404)(assertions) + + return E.runPromise(effect) + }) + + test('propagate a 500 error', () => { + const assertions = E.gen(function* (_) { + const service = yield* _(PrincipalService) + const result = service.fetchPrincipal({ token: 'myToken' }) + const error = yield* _(Effect.flip(result)) + expect(error).toBeInstanceOf(InternalServerError) + }) + + const effect = Fixture.buildErrorEffect(500)(assertions) + + return E.runPromise(effect) + }) +}) diff --git a/packages/node/src/principal/principal.ts b/packages/node/src/principal/principal.ts new file mode 100644 index 0000000..e6cfc77 --- /dev/null +++ b/packages/node/src/principal/principal.ts @@ -0,0 +1,151 @@ +import { + Forbidden, + InternalServerError, + NotFound, + Unauthorized, +} from '@passlock/shared/dist/error/error.js' +import { Principal, createParser } from '@passlock/shared/dist/schema/schema.js' +import type { StreamEmit } from 'effect' +import { Chunk, Console, Context, Effect as E, Layer, Option, Stream, flow, pipe } from 'effect' +import * as https from 'https' +import { Config } from '../config/config.js' + +/* Dependencies */ + +export type StreamResponse = ( + options: https.RequestOptions, +) => Stream.Stream +export const StreamResponse = Context.GenericTag('@services/ResponseStream') + +/* Service */ + +const parsePrincipal = createParser(Principal) + +export type PrincipalErrors = NotFound | Unauthorized | Forbidden | InternalServerError +export type PrincipalRequest = { token: string } + +export type PrincipalService = { + fetchPrincipal: (request: PrincipalRequest) => E.Effect +} + +export const PrincipalService = Context.GenericTag('@services/Principal') + +/* Effects */ + +const buildHostname = (endpoint: string | undefined) => { + return new URL(endpoint || 'https://api.passlock.dev').hostname +} + +const buildOptions = (token: string) => + pipe( + Config, + E.map(({ endpoint, tenancyId, apiKey }) => ({ + hostname: buildHostname(endpoint), + port: 443, + path: `/${tenancyId}/token/${token}`, + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + })), + ) + +export const buildError = (res: { + statusCode?: number | undefined + statusMessage?: string | undefined +}) => { + if (res.statusCode === 404) return new NotFound({ message: 'Invalid token' }) + if (res.statusCode === 401) return new Unauthorized({ message: 'Unauthorized' }) + if (res.statusCode === 403) return new Forbidden({ message: 'Forbidden' }) + + if (res.statusCode && res.statusMessage) + return new InternalServerError({ message: `${String(res.statusCode)} - ${res.statusMessage}` }) + + if (res.statusCode) return new InternalServerError({ message: String(res.statusCode) }) + + if (res.statusMessage) return new InternalServerError({ message: res.statusMessage }) + + return new InternalServerError({ message: 'Received non 200 response' }) +} + +const fail = (error: PrincipalErrors) => E.fail(Option.some(error)) +const succeed = (data: Buffer) => E.succeed(Chunk.of(data)) +const close = E.fail(Option.none()) + +const buildStream = (token: string) => + pipe( + Stream.fromEffect(buildOptions(token)), + Stream.zip(StreamResponse), + Stream.flatMap(([options, streamResponse]) => streamResponse(options)), + ) + +export const fetchPrincipal = ( + request: PrincipalRequest, +): E.Effect => { + const stream = buildStream(request.token) + + const json = pipe( + Stream.runCollect(stream), + E.map(Chunk.toReadonlyArray), + E.map(buffers => Buffer.concat(buffers)), + E.flatMap(buffer => + E.try({ + try: () => buffer.toString(), + catch: e => + new InternalServerError({ + message: 'Unable to convert response to string', + detail: String(e), + }), + }), + ), + E.flatMap(buffer => + E.try({ + try: () => JSON.parse(buffer) as unknown, + catch: e => + new InternalServerError({ + message: 'Unable to parse response to json', + detail: String(e), + }), + }), + ), + E.flatMap(json => + pipe( + parsePrincipal(json), + E.tapError(error => Console.error(error.detail)), + E.mapError( + () => new InternalServerError({ message: 'Unable to parse response as Principal' }), + ), + ), + ), + ) + + return json +} + +/* Live */ + +/* v8 ignore start */ +export const StreamResponseLive = Layer.succeed(StreamResponse, options => { + return Stream.async((emit: StreamEmit.Emit) => { + https + .request(options, res => { + if (200 !== res.statusCode) void emit(fail(buildError(res))) + res.on('data', (data: Buffer) => void emit(succeed(data))) + res.on('close', () => void emit(close)) + res.on('error', e => void emit(fail(new InternalServerError({ message: e.message })))) + }) + .end() + }) +}) + +export const PrincipalServiceLive = Layer.effect( + PrincipalService, + E.gen(function* (_) { + const context = yield* _(E.context()) + return PrincipalService.of({ + fetchPrincipal: flow(fetchPrincipal, E.provide(context)), + }) + }), +) +/* v8 ignore stop */ diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json new file mode 100644 index 0000000..0b38859 --- /dev/null +++ b/packages/node/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "@tsconfig/node18/tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "exactOptionalPropertyTypes": true, + "lib": ["es2023", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "./dist", + "removeComments": true, + "rootDir": "./src", + "sourceMap": true, + "verbatimModuleSyntax": true + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.json" + ], + "exclude": ["./node_modules/**"] +} \ No newline at end of file diff --git a/packages/node/vite.config.ts b/packages/node/vite.config.ts new file mode 100644 index 0000000..77ca5cc --- /dev/null +++ b/packages/node/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'jsdom', + coverage: { + provider: 'v8', + include: ['src/**'], + reporter: ['text', ['html', { subdir: 'html' }]], + exclude: [ + 'src/index.ts', // no real logic here + 'src/config.ts', // no real logic here + 'src/effect.ts', // no real logic here + 'src/**/*.fixture.ts', // test fixtures + 'src/test/fixtures.ts', // test fixtures + ], + }, + }, + server: { + port: 5174 + } +}) diff --git a/packages/shared/.eslintignore b/packages/shared/.eslintignore new file mode 100644 index 0000000..4e4b960 --- /dev/null +++ b/packages/shared/.eslintignore @@ -0,0 +1,2 @@ +/* +!/src \ No newline at end of file diff --git a/packages/shared/.eslintrc.cjs b/packages/shared/.eslintrc.cjs new file mode 100644 index 0000000..540dd96 --- /dev/null +++ b/packages/shared/.eslintrc.cjs @@ -0,0 +1,84 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true + }, + extends: [ + 'eslint:recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/strict-type-checked', + 'prettier' + ], + overrides: [], + parser: "@typescript-eslint/parser", + parserOptions: { + project: './tsconfig.json' + }, + plugins: [ + "@typescript-eslint", + "import" + ], + root: true, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "ignoreRestSiblings": true, + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/consistent-type-definitions": [ + "warn", + "type" + ], + "sort-imports": [ + "warn", + { + ignoreCase: false, + ignoreDeclarationSort: true, // don"t want to sort import lines, use eslint-plugin-import instead + ignoreMemberSort: false, + memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], + allowSeparatedGroups: true, + }, + ], + "import/no-unresolved": "error", + "import/newline-after-import": [ + "warn", + { + "count": 1 + } + ], + 'import/order': [ + 'warn', + { + groups: [ + 'builtin', // Built-in imports (come from NodeJS native) go first + 'external', // <- External imports + 'internal', // <- Absolute imports + ['sibling', 'parent'], // <- Relative imports, the sibling and parent types they can be mingled together + 'index', // <- index imports + 'unknown', // <- unknown + ], + 'newlines-between': 'always', + alphabetize: { + /* sort in ascending order. Options: ["ignore", "asc", "desc"] */ + order: 'asc', + /* ignore case. Options: [true, false] */ + caseInsensitive: true, + }, + }, + ] + }, + settings: { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"] + }, + "import/resolver": { + "typescript": true + } + } +} \ No newline at end of file diff --git a/packages/shared/.gitignore b/packages/shared/.gitignore new file mode 100644 index 0000000..b736c88 --- /dev/null +++ b/packages/shared/.gitignore @@ -0,0 +1,34 @@ +# dependencies +node_modules + +# production +build +dist +dist-ssr +*.local + +# logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# typescript +tsconfig.tsbuildinfo + +# editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# coverage +coverage diff --git a/packages/shared/.prettierrc.json b/packages/shared/.prettierrc.json new file mode 100644 index 0000000..5f7b9ac --- /dev/null +++ b/packages/shared/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": false, + "singleQuote": true, + "arrowParens": "avoid", + "printWidth": 100, + "quoteProps": "consistent", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "ignore", + "bracketSameLine": true, + "useTabs": false +} diff --git a/packages/shared/LICENSE b/packages/shared/LICENSE new file mode 100644 index 0000000..0b9c381 --- /dev/null +++ b/packages/shared/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Passlock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..ccdd694 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,55 @@ +{ + "name": "@passlock/shared", + "version": "0.9.19", + "type": "module", + "description": "Shared code use by Passlock frontend libraries & backend SDKs", + "keywords": [ + "passlock" + ], + "author": { + "name": "Toby Hobson", + "email": "toby@passlock.dev" + }, + "license": "MIT", + "homepage": "https://github.com/passlock-dev/shared", + "files": [ + "dist", + "src" + ], + "scripts": { + "clean": "rimraf ./dist", + "typecheck": "tsc --noEmit", + "build": "tsc --build", + "build:clean": "pnpm run clean && pnpm run build", + "build:watch": "tsc --build --watch", + "format": "prettier --write \"src/**/*.+(js|ts|json)\"", + "lint": "eslint --ext .ts src", + "lint:fix": "pnpm run lint --fix", + "prepublishOnly": "pnpm run clean && pnpm run build", + "ncu": "ncu --peer -x @effect/* -x effect", + "ncu:save": "ncu --peer -x @effect/* -x effect -u" + }, + "devDependencies": { + "@tsconfig/node18": "^18.2.4", + "@types/node": "^20.14.10", + "@typescript-eslint/eslint-plugin": "^7.15.0", + "@typescript-eslint/parser": "^7.15.0", + "@vitest/coverage-v8": "^2.0.3", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-prettier": "^5.1.3", + "fast-check": "^3.19.0", + "prettier": "^3.3.2", + "rimraf": "^5.0.8", + "typescript": "^5.5.3", + "vitest": "^2.0.3" + }, + "dependencies": { + "effect": "^3.4.8", + "@effect/rpc": "^0.31.21", + "@effect/schema": "^0.68.18", + "@effect/platform": "^0.58.21" + } +} diff --git a/packages/shared/src/error/error.ts b/packages/shared/src/error/error.ts new file mode 100644 index 0000000..572afe5 --- /dev/null +++ b/packages/shared/src/error/error.ts @@ -0,0 +1,89 @@ +import * as S from '@effect/schema/Schema' +import { Data } from 'effect' + +export const ErrorCode = { + NotSupported: 'NotSupported', + BadRequest: 'BadRequest', + Duplicate: 'Duplicate', + Forbidden: 'Forbidden', + InternalBrowserError: 'InternalBrowserError', + InternalServerError: 'InternalServerError', + NetworkError: 'NetworkError', + NotFound: 'NotFound', + Disabled: 'Disabled', + Unauthorized: 'Unauthorized', +} as const + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode] + +/* Client errors */ + +/** + * Browser doesn't support passkeys, autofill etc + */ +export class NotSupported extends Data.TaggedError(ErrorCode.NotSupported)<{ + message: string +}> {} + +export class InternalBrowserError extends S.TaggedError()( + ErrorCode.InternalBrowserError, + { + message: S.String, + detail: S.optional(S.String), + }, +) {} + +/* 400 style errors */ + +export class BadRequest extends S.TaggedError()(ErrorCode.BadRequest, { + message: S.String, + detail: S.optional(S.String), +}) {} + +/** + * Email already in use, Passkey already registered etc + */ +export class Duplicate extends S.TaggedError()(ErrorCode.Duplicate, { + message: S.String, + detail: S.optional(S.String), +}) {} + +export class NotFound extends S.TaggedError()(ErrorCode.NotFound, { + message: S.String, + detail: S.optional(S.String), +}) {} + +/** + * User/API key is disabled + */ +export class Disabled extends S.TaggedError()(ErrorCode.Disabled, { + message: S.String, + detail: S.optional(S.String), +}) {} + +/* Permissions */ + +export class Unauthorized extends S.TaggedError()(ErrorCode.Unauthorized, { + message: S.String, + detail: S.optional(S.String), +}) {} + +export class Forbidden extends S.TaggedError()(ErrorCode.Forbidden, { + message: S.String, + detail: S.optional(S.String), +}) {} + +/* Other errors */ + +export class NetworkError extends S.TaggedError()(ErrorCode.NetworkError, { + message: S.String, + detail: S.optional(S.String), +}) {} + +export class InternalServerError extends S.TaggedError()( + ErrorCode.InternalServerError, + { + message: S.String, + detail: S.optional(S.String), + }, +) {} diff --git a/packages/shared/src/rpc/authentication.ts b/packages/shared/src/rpc/authentication.ts new file mode 100644 index 0000000..9c84ad7 --- /dev/null +++ b/packages/shared/src/rpc/authentication.ts @@ -0,0 +1,87 @@ +import * as S from '@effect/schema/Schema' +import { Context, Effect as E, Layer } from 'effect' + +import { BadRequest, Disabled, Forbidden, NotFound, Unauthorized } from '../error/error.js' + +import { + AuthenticationCredential, + AuthenticationOptions, + Principal, + UserVerification, +} from '../schema/schema.js' +import { makePostRequest } from './client.js' +import { Dispatcher } from './dispatcher.js' + +/* Options */ + +export class OptionsReq extends S.Class(`@passkey/auth/optionsReq`)({ + email: S.optional(S.String, { exact: true }), + userVerification: S.optional(UserVerification, { exact: true }), +}) {} + +export class OptionsRes extends S.Class('@passkey/auth/optionsRes')({ + session: S.String, + publicKey: AuthenticationOptions, +}) {} + +export const OptionsErrors = S.Union(BadRequest, NotFound) + +export type OptionsErrors = S.Schema.Type + +/* Verification */ + +export class VerificationReq extends S.Class("@passkey/auth/verificationReq")({ + session: S.String, + credential: AuthenticationCredential, +}) {} + +export class VerificationRes extends S.Class('@passkey/auth/verificationRes')({ + principal: Principal, +}) {} + +export const VerificationErrors = S.Union(BadRequest, Unauthorized, Forbidden, Disabled) + +export type VerificationErrors = S.Schema.Type + +/* Service */ + +export type AuthenticationService = { + getAuthenticationOptions: ( + req: OptionsReq + ) => E.Effect + + verifyAuthenticationCredential: ( + req: VerificationReq, + ) => E.Effect +} + +/* Client */ + +export const OPTIONS_ENDPOINT = '/passkey/auth/options' +export const VERIFY_ENDPOINT = '/passkey/auth/verify' + +export class AuthenticationClient extends Context.Tag("@passkey/auth/client")< + AuthenticationClient, + AuthenticationService +>() {} + +export const AuthenticationClientLive = Layer.effect( + AuthenticationClient, + E.gen(function* (_) { + const dispatcher = yield* _(Dispatcher) + const optionsResolver = makePostRequest(OptionsReq, OptionsRes, OptionsErrors, dispatcher) + const verifyResolver = makePostRequest(VerificationReq, VerificationRes, VerificationErrors, dispatcher) + + return { + getAuthenticationOptions: req => optionsResolver(OPTIONS_ENDPOINT, req), + verifyAuthenticationCredential: (req) => verifyResolver(VERIFY_ENDPOINT, req) + } + }) +) + +/* Handler */ + +export class AuthenticationHandler extends Context.Tag("@passkey/auth/handler")< + AuthenticationHandler, + AuthenticationService +>() {} \ No newline at end of file diff --git a/packages/shared/src/rpc/client.ts b/packages/shared/src/rpc/client.ts new file mode 100644 index 0000000..505d012 --- /dev/null +++ b/packages/shared/src/rpc/client.ts @@ -0,0 +1,43 @@ +import * as S from '@effect/schema/Schema' +import { Effect as E, pipe } from "effect" +import { Dispatcher } from "./dispatcher.js" + +export const makeGetRequest = ( + responseSchema: S.Schema, + errorSchema: S.Schema, + dispatcher: Dispatcher['Type'] +) => (path: string) => pipe( + dispatcher.get(path), + E.flatMap(res => { + if (res.status === 200) return S.decodeUnknown(responseSchema)(res.body) + return pipe( + S.decodeUnknown(errorSchema)(res.body), + E.flatMap(err => E.fail(err)) + ) + }), + E.catchTag('ParseError', e => E.die(e)), + E.catchTag('NetworkError', e => E.die(e)) +) + +export const makePostRequest = ( + requestSchema: S.Schema, + responseSchema: S.Schema, + errorSchema: S.Schema, + dispatcher: Dispatcher['Type'] +) => (path: string, request: RI) => { + return pipe( + S.encode(requestSchema)(request), + E.flatMap(request => + dispatcher.post(path, JSON.stringify(request)) + ), + E.flatMap(res => { + if (res.status === 200) return S.decodeUnknown(responseSchema)(res.body) + return pipe( + S.decodeUnknown(errorSchema)(res.body), + E.flatMap(err => E.fail(err)) + ) + }), + E.catchTag('ParseError', e => E.die(e)), + E.catchTag('NetworkError', e => E.die(e)) + ) +} \ No newline at end of file diff --git a/packages/shared/src/rpc/config.ts b/packages/shared/src/rpc/config.ts new file mode 100644 index 0000000..bfe52bf --- /dev/null +++ b/packages/shared/src/rpc/config.ts @@ -0,0 +1,17 @@ +import { Context, Schedule } from "effect" + +export class RpcConfig extends Context.Tag('@rpc/RpcConfig')< + RpcConfig, + { + endpoint?: string + tenancyId: string + clientId: string + } +>() {} + +export class RetrySchedule extends Context.Tag('@rpc/RetrySchedule')< + RetrySchedule, + { + schedule: Schedule.Schedule + } +>() {} \ No newline at end of file diff --git a/packages/shared/src/rpc/connection.ts b/packages/shared/src/rpc/connection.ts new file mode 100644 index 0000000..837e980 --- /dev/null +++ b/packages/shared/src/rpc/connection.ts @@ -0,0 +1,42 @@ +import * as S from '@effect/schema/Schema' +import { Context, Effect as E, Layer } from 'effect' +import { makeGetRequest } from './client.js' +import { Dispatcher } from './dispatcher.js' + +/* Pre connect */ + +export class ConnectRes extends S.Class('@connection/preConnectRes')({ + warmed: S.Boolean, +}) {} + +export type ConnectionService = { + preConnect: () => E.Effect +} + +/* Client */ + +export const CONNECT_ENDPOINT = '/connection/pre-connect' + +export class ConnectionClient extends Context.Tag("@connection/client")< + ConnectionClient, + ConnectionService +>() {} + +export const ConnectionClientLive = Layer.effect( + ConnectionClient, + E.gen(function* (_) { + const dispatcher = yield* _(Dispatcher) + const preConnectResolver = makeGetRequest(ConnectRes, S.Never, dispatcher) + + return { + preConnect: () => preConnectResolver(CONNECT_ENDPOINT) + } + }) +) + +/* Handler */ + +export class ConnectionHandler extends Context.Tag("@connection/handler")< +ConnectionHandler, + ConnectionService +>() {} \ No newline at end of file diff --git a/packages/shared/src/rpc/dispatcher.ts b/packages/shared/src/rpc/dispatcher.ts new file mode 100644 index 0000000..4d3c131 --- /dev/null +++ b/packages/shared/src/rpc/dispatcher.ts @@ -0,0 +1,147 @@ +import { make as makeEffect } from '@effect/rpc/ResolverNoStream' +import { Context, Effect as E, Layer } from 'effect' + +import type { Router } from '@effect/rpc' +import { NetworkError } from '../error/error.js' +import { RetrySchedule, RpcConfig } from './config.js' + +/* Services */ + +export type DispatcherResponse = { + status: number + body: object +} + +/** To send the JSON to the backend */ +export class Dispatcher extends Context.Tag('@rpc/Dispatcher')< + Dispatcher, + { + get: (path: string) => E.Effect + post: (path: string, body: string) => E.Effect + } +>() {} + +/** Fires off requests using a Dispatcher */ +export const dispatchResolver = >(router: R) => makeEffect(u => { + return E.gen(function* (_) { + const dispatcher = yield* _(Dispatcher) + + const requestBody = yield* _( + E.try({ + try: () => JSON.stringify(u), + catch: () => new NetworkError({ message: 'Unable to serialize RPC request' }), + }), + ) + + return yield* _(dispatcher.post('/rpc', requestBody)) + }) +})() + +/** Fires off client requests using fetch */ +/** TODO: Write tests */ +/** TODO: Evaluate platform/http client (if now stable) */ +export const DispatcherLive = Layer.effect( + Dispatcher, + E.gen(function* (_) { + const { schedule } = yield* _(RetrySchedule) + const { tenancyId, clientId, endpoint: maybeEndpoint } = yield* _(RpcConfig) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parseJson = (res: Response, url: string) => + E.tryPromise({ + try: () => res.json() as Promise, + catch: e => + new NetworkError({ + message: 'Unable to extract json response from ' + url, + detail: String(e), + }), + }) + + // 400 errors are reflected in the RPC response error channel + // so in network terms they're still "ok" + const assertNo500s = (res: Response, url: string) => { + if (res.status >= 500) { + return E.fail( + new NetworkError({ + message: 'Received 500 response code from ' + url, + }), + ) + } else return E.void + } + + const parseJsonObject = (json: unknown) => { + return typeof json === 'object' && json !== null + ? E.succeed(json) + : E.fail( + new NetworkError({ + message: `Expected JSON object to be returned from RPC endpoint, actual ${typeof json}`, + }), + ) + } + + const buildUrl = (_path: string) => { + const endpoint = maybeEndpoint || 'https://api.passlock.dev' + // drop leading / + const path = _path.replace(/^\//, '') + return `${endpoint}/${tenancyId}/${path}` + } + + return { + get: (path: string) => { + const effect = E.gen(function* (_) { + const headers = { + 'Accept': 'application/json', + 'X-CLIENT-ID': clientId, + } + + const url = buildUrl(path) + + const res = yield* _( + E.tryPromise({ + try: () => fetch(url, { method: 'GET', headers }), + catch: e => + new NetworkError({ message: 'Unable to fetch from ' + url, detail: String(e) }), + }), + ) + + const json = yield* _(parseJson(res, url)) + yield* _(assertNo500s(res, url)) + const jsonObject = yield* _(parseJsonObject(json)) + + return { status: res.status, body: jsonObject } + }) + + return E.retry(effect, { schedule }) + }, + + post: (_path: string, body: string) => { + const effect = E.gen(function* (_) { + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CLIENT-ID': clientId, + } + + // drop leading / + const url = buildUrl(_path) + + const res = yield* _( + E.tryPromise({ + try: () => fetch(url, { method: 'POST', headers, body }), + catch: e => + new NetworkError({ message: 'Unable to fetch from ' + url, detail: String(e) }), + }), + ) + + const json = yield* _(parseJson(res, url)) + yield* _(assertNo500s(res, url)) + const jsonObject = yield* _(parseJsonObject(json)) + + return { status: res.status, body: jsonObject } + }) + + return E.retry(effect, { schedule }) + }, + } + }) +) diff --git a/packages/shared/src/rpc/registration.ts b/packages/shared/src/rpc/registration.ts new file mode 100644 index 0000000..da7c4c3 --- /dev/null +++ b/packages/shared/src/rpc/registration.ts @@ -0,0 +1,90 @@ +import * as S from '@effect/schema/Schema' +import { Context, Effect as E, Layer } from 'effect' + +import { BadRequest, Duplicate, Forbidden, Unauthorized } from '../error/error.js' +import { + Principal, + RegistrationCredential, + RegistrationOptions, + UserVerification, + VerifyEmail, +} from '../schema/schema.js' +import { makePostRequest } from './client.js' +import { Dispatcher } from './dispatcher.js' + +/* Options */ + +export class OptionsReq extends S.Class('@passkey/register/optionsReq')({ + email: S.String, + givenName: S.String, + familyName: S.String, + userVerification: S.optional(UserVerification), + verifyEmail: S.optional(VerifyEmail), + redirectUrl: S.optional(S.String), +}) {} + +export class OptionsRes extends S.Class('@passkey/register/optionsRes')({ + session: S.String, + publicKey: RegistrationOptions, +}) {} + +export const OptionsErrors = S.Union(BadRequest, Duplicate) + +export type OptionsErrors = S.Schema.Type + +/* Verification */ + +export class VerificationReq extends S.Class('@passkey/register/verificationReq')({ + session: S.String, + credential: RegistrationCredential, + verifyEmail: S.optional(VerifyEmail), + redirectUrl: S.optional(S.String), +}) {} + +export class VerificationRes extends S.Class('@passkey/register/verificationRes')({ + principal: Principal, +}) {} + +export const VerificationErrors = S.Union(BadRequest, Duplicate, Unauthorized, Forbidden) + +export type VerificationErrors = S.Schema.Type + +/* Service */ + +export type RegistrationService = { + getRegistrationOptions: (req: OptionsReq) => E.Effect + verifyRegistrationCredential: ( + req: VerificationReq, + ) => E.Effect +} + +/* Client */ + +export const OPTIONS_ENDPOINT = '/passkey/register/options' +export const VERIFY_ENDPOINT = '/passkey/register/verify' + +export class RegistrationClient extends Context.Tag('@passkey/register/client')< + RegistrationClient, + RegistrationService +>() {} + +export const RegistrationClientLive = Layer.effect( + RegistrationClient, + E.gen(function* (_) { + const dispatcher = yield* _(Dispatcher) + const optionsResolver = makePostRequest(OptionsReq, OptionsRes, OptionsErrors, dispatcher) + const verifyResolver = makePostRequest(VerificationReq, VerificationRes, VerificationErrors, dispatcher) + + return { + getRegistrationOptions: req => optionsResolver("/passkey/register/options", req), + verifyRegistrationCredential: req => verifyResolver("/passkey/register/verify", req) + } + }) +) + +/* Handler */ + +export class RegistrationHandler extends Context.Tag('@passkey/register/handler')< + RegistrationHandler, + RegistrationService +>() {} diff --git a/packages/shared/src/rpc/social.ts b/packages/shared/src/rpc/social.ts new file mode 100644 index 0000000..ef1447a --- /dev/null +++ b/packages/shared/src/rpc/social.ts @@ -0,0 +1,82 @@ +import * as S from '@effect/schema/Schema' +import { Context, Effect as E, Layer } from 'effect' + +import { BadRequest, Disabled, Duplicate, Forbidden, NotFound, Unauthorized } from '../error/error.js' +import { + Principal +} from '../schema/schema.js' +import { makePostRequest } from './client.js' +import { Dispatcher } from './dispatcher.js' + +const Provider = S.Literal('apple', 'google') + +/* Registration */ + +export class PrincipalRes extends S.Class('@social/principalRes')({ principal: Principal }) {} + +export class RegisterOidcReq extends S.Class('@social/oidc/registerReq')({ + provider: Provider, + idToken: S.String, + givenName: S.Option(S.String), + familyName: S.Option(S.String), + nonce: S.String, +}) {} + +export const RegisterOidcErrors = S.Union(BadRequest, Unauthorized, Forbidden, Disabled, Duplicate) + +export type RegisterOidcErrors = S.Schema.Type + +/* Authentication */ + +export class AuthOidcReq extends S.Class('@social/oidc/authReq')({ + provider: Provider, + idToken: S.String, + nonce: S.String, +}) {} + +export const AuthOidcErrors = S.Union(BadRequest, Unauthorized, Forbidden, Disabled, NotFound) + +export type AuthOidcErrors = S.Schema.Type + +/* Service */ + +export type SocialService = { + registerOidc: ( + req: RegisterOidcReq, + ) => E.Effect + + authenticateOidc: ( + req: AuthOidcReq, + ) => E.Effect +} + +/* Client */ + +export const OIDC_REGISTER_ENDPOINT = '/social/oidc/register' +export const OIDC_AUTH_ENDPOINT = '/social/oidc/auth' + +export class SocialClient extends Context.Tag('@social/client')< + SocialClient, + SocialService +>() {} + +export const SocialClientLive = Layer.effect( + SocialClient, + E.gen(function* (_) { + const dispatcher = yield* _(Dispatcher) + const registerResolver = makePostRequest(RegisterOidcReq, PrincipalRes, RegisterOidcErrors, dispatcher) + const authenticateResolver = makePostRequest(AuthOidcReq, PrincipalRes, AuthOidcErrors, dispatcher) + + return { + registerOidc: req => registerResolver(OIDC_REGISTER_ENDPOINT, req), + authenticateOidc: req => authenticateResolver(OIDC_AUTH_ENDPOINT, req) + } + }) +) + +/* Handler */ + +export class SocialHandler extends Context.Tag('@social/handler')< + SocialHandler, + SocialService +>() {} \ No newline at end of file diff --git a/packages/shared/src/rpc/user.ts b/packages/shared/src/rpc/user.ts new file mode 100644 index 0000000..f009717 --- /dev/null +++ b/packages/shared/src/rpc/user.ts @@ -0,0 +1,89 @@ +import * as S from '@effect/schema/Schema' +import { Context, Effect as E, Layer } from 'effect' + +import { Principal, VerifyEmail } from '../schema/schema.js' + +import { BadRequest, Disabled, Forbidden, NotFound, Unauthorized } from '../error/error.js' +import { makePostRequest } from './client.js' +import { Dispatcher } from './dispatcher.js' + +/* Is existing user */ + +export class IsExistingUserReq extends S.Class('@user/isExistingUserReq')({ + email: S.String +}) {} + +export class IsExistingUserRes extends S.Class('@user/isExistingUserRes')({ + existingUser: S.Boolean, + detail: S.optional(S.String), +}) {} + +/* Verify email */ + +export class VerifyEmailReq extends S.Class('@user/verifyEmailReq')({ + code: S.String, + token: S.String, +}) {} + +export class VerifyEmailRes extends S.Class('@user/verifyEmailRes')({ + principal: Principal, +}) {} + +export const VerifyEmailErrors = S.Union(BadRequest, NotFound, Disabled, Unauthorized, Forbidden) + +export type VerifyEmailErrors = S.Schema.Type + +/* Resend email */ + +export class ResendEmailReq extends S.Class('@user/resendEmailReq',)({ + userId: S.String, + verifyEmail: VerifyEmail, + }) { } + +export class ResendEmailRes extends S.Class('@user/resendEmailRes')({ }) {} + +export const ResendEmailErrors = S.Union(BadRequest, NotFound, Disabled) + +export type ResendEmailErrors = S.Schema.Type + +/* Service */ + +export type UserService = { + isExistingUser: (req: IsExistingUserReq) => E.Effect + verifyEmail: (req: VerifyEmailReq) => E.Effect + resendVerificationEmail: (req: ResendEmailReq) => E.Effect +} + +/* Client */ + +export const USER_STATUS_ENDPOINT = '/user/status' +export const VERIFY_EMAIL_ENDPOINT = '/user/verify-email' +export const RESEND_EMAIL_ENDPOINT = '/user/verify-email/resend' + +export class UserClient extends Context.Tag('@user/client')< + UserClient, + UserService +>() {} + +export const UserClientLive = Layer.effect( + UserClient, + E.gen(function* (_) { + const dispatcher = yield* _(Dispatcher) + const isExistingUserResolver = makePostRequest(IsExistingUserReq, IsExistingUserRes, S.Never, dispatcher) + const verifyEmailResolver = makePostRequest(VerifyEmailReq, VerifyEmailRes, VerifyEmailErrors, dispatcher) + const resendEmailResolver = makePostRequest(ResendEmailReq, ResendEmailRes, ResendEmailErrors, dispatcher) + + return { + isExistingUser: req => isExistingUserResolver(USER_STATUS_ENDPOINT, req), + verifyEmail: req => verifyEmailResolver(VERIFY_EMAIL_ENDPOINT, req), + resendVerificationEmail: req => resendEmailResolver(RESEND_EMAIL_ENDPOINT, req) + } + }) +) + +/* Handler */ + +export class UserHandler extends Context.Tag('@user/handler')< + UserClient, + UserService +>() {} \ No newline at end of file diff --git a/packages/shared/src/schema/schema.ts b/packages/shared/src/schema/schema.ts new file mode 100644 index 0000000..3022aa1 --- /dev/null +++ b/packages/shared/src/schema/schema.ts @@ -0,0 +1,212 @@ +import * as S from '@effect/schema/Schema' +import { formatError } from '@effect/schema/TreeFormatter' +import { Effect as E, pipe } from 'effect' + +const optional = (s: S.Schema) => S.optional(s, { exact: true }) + +export class ParsingError extends S.TaggedError()('ParsingError', { + message: S.String, + detail: S.String, +}) {} + +/* Components */ + +export const VerifyEmailLink = S.Struct({ + method: S.Literal('link'), + redirectUrl: S.String +}) + +export type VerifyEmailLink = S.Schema.Type + +export const VerifyEmailCode = S.Struct({ + method: S.Literal('code'), +}) + +export type VerifyEmailCode = S.Schema.Type + +export const VerifyEmail = S.Union(VerifyEmailLink, VerifyEmailCode) + +export type VerifyEmail = S.Schema.Type + +const PublicKey = S.Literal('public-key') + +const PubKeyCredParams = S.Struct({ + alg: S.Number, + type: PublicKey, +}) + +const AuthenticatorAttachment = S.Union(S.Literal('cross-platform'), S.Literal('platform')) + +const base64url = S.String + +export const Transport = S.Union( + S.Literal('ble'), + S.Literal('hybrid'), + S.Literal('internal'), + S.Literal('nfc'), + S.Literal('usb'), +) + +const Credential = S.Struct({ + id: base64url, + type: PublicKey, + transports: optional(S.mutable(S.Array(Transport))), +}) + +export const UserVerification = S.Union( + S.Literal('discouraged'), + S.Literal('preferred'), + S.Literal('required'), +) + +export type UserVerification = S.Schema.Type + +const ResidentKey = S.Union(S.Literal('discouraged'), S.Literal('preferred'), S.Literal('required')) + +const AuthenticatorSelection = S.Struct({ + authenticatorAttachment: optional(AuthenticatorAttachment), + requireResidentKey: optional(S.Boolean), + residentKey: optional(ResidentKey), + userVerification: optional(UserVerification), +}) + +export const AuthType = S.Literal('email', 'apple', 'google', 'passkey') + +export type AuthType = S.Schema.Type + +/* Registration */ + +/** + * Required by the browser to generate a passkey. + * Wrap this into a publicKey and pass into createRequestFromJSON + * i.e. createRequestFromJSON({ publicKey: RegistrationOptions }) + */ +export const RegistrationOptions = S.Struct({ + rp: S.Struct({ + name: S.String, + id: optional(base64url), + }), + user: S.Struct({ + id: base64url, + name: S.String, + displayName: S.String, + }), + challenge: base64url, + pubKeyCredParams: S.mutable(S.Array(PubKeyCredParams)), + timeout: optional(S.Number), + excludeCredentials: optional(S.mutable(S.Array(Credential))), + authenticatorSelection: optional(AuthenticatorSelection), + attestation: optional( + S.Union(S.Literal('direct'), S.Literal('enterprise'), S.Literal('indirect'), S.Literal('none')), + ), + extensions: optional( + S.Struct({ + appid: optional(S.String), + appidExclude: optional(S.String), + credProps: optional(S.Boolean), + }), + ), +}) + +export type RegistrationOptions = S.Schema.Type + +/** Public key credential (generated by the browser) */ +export const RegistrationCredential = S.Struct({ + id: S.String, + type: PublicKey, + rawId: S.String, + authenticatorAttachment: S.optional(S.NullishOr(AuthenticatorAttachment)), + response: S.Struct({ + clientDataJSON: S.String, + attestationObject: S.String, + transports: S.mutable(S.Array(Transport)), + }), + clientExtensionResults: S.Struct({ + appid: S.optional(S.Boolean), + appidExclude: S.optional(S.Boolean), + credProps: S.optional(S.Struct({ rk: S.Boolean })), + }), +}) + +export type RegistrationCredential = S.Schema.Type + +/* Authentication */ + +export const AuthenticationOptions = S.Struct({ + challenge: S.String, + timeout: optional(S.Number), + rpId: optional(S.String), + allowCredentials: optional(S.mutable(S.Array(Credential))), + userVerification: optional(UserVerification), + extensions: optional( + S.Struct({ + appid: optional(S.String), + credProps: optional(S.Boolean), + hmacCreateSecret: optional(S.Boolean), + }), + ), +}) + +export type AuthenticationOptions = S.Schema.Type + +/** Browser's response to the backend's auth challenge */ +export const AuthenticationCredential = S.Struct({ + id: S.String, + type: PublicKey, + rawId: S.String, + authenticatorAttachment: S.optional(S.NullishOr(S.String)), + response: S.Struct({ + clientDataJSON: S.String, + authenticatorData: S.String, + signature: S.String, + userHandle: S.NullishOr(S.String), + }), + clientExtensionResults: S.Struct({ + appid: S.optional(S.Boolean), + appidExclude: S.optional(S.Boolean), + credProps: S.optional( + S.Struct({ + rk: S.Boolean, + }), + ), + }), +}) + +export type AuthenticationCredential = S.Schema.Type + +/** Represents a successful registration/authentication */ +export const Principal = S.Struct({ + token: S.String, + user: S.Struct({ + id: S.String, + givenName: S.String, + familyName: S.String, + email: S.String, + emailVerified: S.Boolean, + }), + authStatement: S.Struct({ + authType: AuthType, + userVerified: S.Boolean, + authTimestamp: S.Date, + }), + expireAt: S.Date, +}) + +export type Principal = S.Schema.Type + +export const AuthenticationRequired = S.Struct({ + requiredAuthType: AuthType, +}) + +/* Utils */ + +export const createParser = + (schema: S.Schema) => + (input: unknown) => + pipe( + S.decodeUnknown(schema)(input), + E.flip, + E.flatMap(formatError), + E.map(detail => new ParsingError({ message: 'Unable to parse input', detail })), + E.flip + ) diff --git a/packages/shared/src/utils/typescript.ts b/packages/shared/src/utils/typescript.ts new file mode 100644 index 0000000..45db184 --- /dev/null +++ b/packages/shared/src/utils/typescript.ts @@ -0,0 +1,37 @@ +/** + * Because { a: string, b: undefined } !== { a: string } + * Usage: { a: "a", ...(copyIfDefined(source, 'b')) } + * + * @param input + * @param field + * @returns + */ +export const copyIfDefined = (input: T, field: keyof T) => { + return { + ...(input[field] && { [field]: input[field] }), + } +} + +/* eslint-disable */ +type Undefined = T extends null + ? undefined + : T extends (infer U)[] + ? Undefined[] + : T extends Record + ? { [K in keyof T]: Undefined } + : T + +export const nullsToUndefined = (obj: T): Undefined => { + if (obj === null || obj === undefined) { + return undefined as any + } + + if ((obj as any).constructor.name === 'Object' || Array.isArray(obj)) { + for (const key in obj) { + obj[key] = nullsToUndefined(obj[key]) as any + } + } + + return obj as any +} +/* eslint-enable */ diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..0b38859 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "@tsconfig/node18/tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "exactOptionalPropertyTypes": true, + "lib": ["es2023", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "./dist", + "removeComments": true, + "rootDir": "./src", + "sourceMap": true, + "verbatimModuleSyntax": true + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.json" + ], + "exclude": ["./node_modules/**"] +} \ No newline at end of file diff --git a/packages/shared/vite.config.ts b/packages/shared/vite.config.ts new file mode 100644 index 0000000..f0f12d5 --- /dev/null +++ b/packages/shared/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + coverage: { + provider: 'v8', + include: ['src/**'], + reporter: ['text', ['html', { subdir: 'html' }]], + exclude: [], + }, + }, +}) diff --git a/packages/sveltekit/.gitignore b/packages/sveltekit/.gitignore new file mode 100644 index 0000000..715b548 --- /dev/null +++ b/packages/sveltekit/.gitignore @@ -0,0 +1,22 @@ +node_modules + +# Output +.output +.vercel +/.svelte-kit +/build +/dist + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/packages/sveltekit/.npmrc b/packages/sveltekit/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/packages/sveltekit/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/packages/sveltekit/.prettierignore b/packages/sveltekit/.prettierignore new file mode 100644 index 0000000..ab78a95 --- /dev/null +++ b/packages/sveltekit/.prettierignore @@ -0,0 +1,4 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/packages/sveltekit/.prettierrc b/packages/sveltekit/.prettierrc new file mode 100644 index 0000000..9573023 --- /dev/null +++ b/packages/sveltekit/.prettierrc @@ -0,0 +1,8 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/packages/sveltekit/eslint.config.js b/packages/sveltekit/eslint.config.js new file mode 100644 index 0000000..a351fa9 --- /dev/null +++ b/packages/sveltekit/eslint.config.js @@ -0,0 +1,33 @@ +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import svelte from 'eslint-plugin-svelte'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; + +/** @type {import('eslint').Linter.FlatConfig[]} */ +export default [ + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs['flat/recommended'], + prettier, + ...svelte.configs['flat/prettier'], + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + } + }, + { + files: ['**/*.svelte'], + languageOptions: { + parserOptions: { + parser: ts.parser + } + } + }, + { + ignores: ['build/', '.svelte-kit/', 'dist/'] + } +]; diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json new file mode 100644 index 0000000..7b88ec8 --- /dev/null +++ b/packages/sveltekit/package.json @@ -0,0 +1,112 @@ +{ + "name": "@passlock/sveltekit", + "version": "0.9.20", + "description": "Easy passkey authentication and social login for SvelteKit apps. Works with Superforms and regular form actions.", + "keywords": [ + "passkey", + "passkeys", + "webauthn", + "google one tap", + "sign in with google", + "sign in with apple", + "react", + "next", + "vue", + "nuxt", + "svelte" + ], + "author": { + "name": "Toby Hobson", + "email": "toby@passlock.dev" + }, + "license": "MIT", + "homepage": "https://passlock.dev", + "repository": "github.com/passlock-dev/svelte-passkeys", + "bugs": { + "url": "https://github.com/passlock-dev/svelte-passkeys/issues", + "email": "team@passlock.dev" + }, + "scripts": { + "dev": "vite dev", + "clean": "rimraf ./dist", + "typecheck": "pnpm run check:errors", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:errors": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold error", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", + "test:watch": "vitest dev", + "test:ui": "vitest --coverage.enabled=true --ui", + "test:coverage": "vitest run --coverage", + "format": "prettier --write \"src/**/*.+(js|ts|json|svelte)\"", + "lint": "eslint --ext .ts --ext .svelte src", + "lint:fix": "pnpm run lint --fix", + "build": "svelte-kit sync && svelte-package && publint", + "build:clean": "pnpm run clean && pnpm run build", + "prepublishOnly": "pnpm run build:clean", + "ncu": "ncu --peer -x @effect/* -x effect", + "ncu:save": "ncu --peer -x @effect/* -x effect -u" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "svelte": "./dist/index.js" + }, + "./superforms": { + "types": "./dist/superforms/index.d.ts", + "import": "./dist/superforms/index.js", + "svelte": "./dist/superforms/index.js" + }, + "./components/social": { + "types": "./dist/components/social/index.d.ts", + "import": "./dist/components/social/index.js", + "svelte": "./dist/components/social/index.js" + } + }, + "files": [ + "src", + "dist", + "!dist/**/*.test.*", + "!dist/**/*.spec.*" + ], + "peerDependencies": { + "svelte": "^4.0.0", + "sveltekit-superforms": "^2.15.2" + }, + "peerDependenciesMeta": { + "sveltekit-superforms": { + "optional": true + } + }, + "dependencies": { + "@passlock/client": "workspace:*" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/package": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@types/apple-signin-api": "^1.5.3", + "@types/eslint": "^8.56.7", + "@types/google-one-tap": "^1.2.6", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-svelte": "^2.36.0", + "globals": "^15.0.0", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "publint": "^0.1.9", + "rimraf": "^5.0.8", + "svelte": "^4.2.7", + "svelte-check": "^3.6.0", + "sveltekit-superforms": "^2.15.2", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "typescript-eslint": "^8.0.0-alpha.20", + "vite": "^5.0.11", + "vitest": "^2.0.3" + }, + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module" +} diff --git a/packages/sveltekit/src/lib/components/social/Apple.svelte b/packages/sveltekit/src/lib/components/social/Apple.svelte new file mode 100644 index 0000000..7313791 --- /dev/null +++ b/packages/sveltekit/src/lib/components/social/Apple.svelte @@ -0,0 +1,145 @@ + + + + + + + + diff --git a/packages/sveltekit/src/lib/components/social/Google.svelte b/packages/sveltekit/src/lib/components/social/Google.svelte new file mode 100644 index 0000000..6dd753d --- /dev/null +++ b/packages/sveltekit/src/lib/components/social/Google.svelte @@ -0,0 +1,151 @@ + + + + + +
    + +
    +
    + + + + + + + + + +
    + + +
    + + + + + + setMode('light')}> + + Light + + setMode('dark')}> + + Dark + + resetMode()}> + + System + + + logoutForm.requestSubmit()}> + Logout + + + +
    +
    +
    +
    + + + Your Orders + + Introducing Our Dynamic Orders Dashboard for Seamless Management + and Insightful Analysis. + + + + + + + + + This Week + $1329 + + +
    + +25% from last week +
    +
    + + + +
    + + + This Month + $5,329 + + +
    + +10% from last month +
    +
    + + + +
    +
    + +
    + + Week + Month + Year + +
    + + + + + + Filter by + + + Fulfilled + + + Declined + + + Refunded + + + + +
    +
    + + + + Orders + + Recent orders from your store. + + + + + + + Customer + + + + Amount + + + + + +
    Liam Johnson
    + +
    + + + + $250.00 +
    + + +
    Olivia Smith
    + +
    + + + + $150.00 +
    + + +
    Noah Williams
    + +
    + + + + $350.00 +
    + + +
    Emma Brown
    + +
    + + + + $450.00 +
    + + +
    Liam Johnson
    + +
    + + + + $250.00 +
    + + +
    Liam Johnson
    + +
    + + + + $250.00 +
    + + +
    Olivia Smith
    + +
    + + + + $150.00 +
    + + +
    Emma Brown
    + +
    + + + + $450.00 +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    + + Order Oe31b70H + + + Date: November 23, 2023 +
    +
    + + + + + + + Edit + Export + + Trash + + +
    +
    + +
    +
    Order Details
    +
      +
    • + + Glimmer Lamps x 2 + + $250.00 +
    • +
    • + + Aqua Filters x 1 + + $49.00 +
    • +
    + +
      +
    • + Subtotal + $299.00 +
    • +
    • + Shipping + $5.00 +
    • +
    • + Tax + $25.00 +
    • +
    • + Total + $329.00 +
    • +
    +
    + +
    +
    +
    Shipping Information
    +
    + Liam Johnson + 1234 Main St. + Anytown, CA 12345 +
    +
    +
    +
    Billing Information
    +
    + Same as shipping address +
    +
    +
    + +
    +
    Customer Information
    +
    +
    +
    Customer
    +
    Liam Johnson
    +
    +
    +
    Email
    +
    + liam@acme.com +
    +
    +
    +
    Phone
    +
    + +1 234 567 890 +
    +
    +
    +
    + +
    +
    Payment Information
    +
    +
    +
    + + Visa +
    +
    **** **** **** 4532
    +
    +
    +
    +
    + +
    + Updated +
    + + + + + + + + + + +
    +
    +
    +
    +
    +