OAuth: Reset password & Sign-up (#2945)
* Adds "password reset" during OAuth flows * Adds "Sign up" during OAuth flows * Adds support for multiple languages in the OAuth flow * Adds "fr" translation for the OAuth flow Co-authored-by: devin ivy <devinivy@gmail.com> Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
parent
442fcce308
commit
850e39843c
5
.changeset/brave-countries-return.md
Normal file
5
.changeset/brave-countries-return.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/oauth-provider": patch
|
||||
---
|
||||
|
||||
Add support for password reset
|
5
.changeset/eleven-ducks-boil.md
Normal file
5
.changeset/eleven-ducks-boil.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto-labs/fetch": patch
|
||||
---
|
||||
|
||||
Improved error response parsing
|
5
.changeset/hip-feet-play.md
Normal file
5
.changeset/hip-feet-play.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/oauth-provider": minor
|
||||
---
|
||||
|
||||
Add support for account sign-up
|
5
.changeset/hungry-buttons-clean.md
Normal file
5
.changeset/hungry-buttons-clean.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/oauth-client-browser-example": patch
|
||||
---
|
||||
|
||||
Update react to version 19
|
7
.changeset/long-bats-guess.md
Normal file
7
.changeset/long-bats-guess.md
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
"@atproto/oauth-provider": patch
|
||||
"@atproto/oauth-types": patch
|
||||
"@atproto/jwk": patch
|
||||
---
|
||||
|
||||
Properly support locales with 3 chars (Asturian)
|
5
.changeset/rotten-hornets-develop.md
Normal file
5
.changeset/rotten-hornets-develop.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/oauth-provider": patch
|
||||
---
|
||||
|
||||
Add support for multiple locales
|
5
.changeset/short-masks-punch.md
Normal file
5
.changeset/short-masks-punch.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/syntax": patch
|
||||
---
|
||||
|
||||
Deprecate unused classes
|
5
.changeset/sour-guests-work.md
Normal file
5
.changeset/sour-guests-work.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto-labs/rollup-plugin-bundle-manifest": patch
|
||||
---
|
||||
|
||||
Improve typing of plugin
|
5
.changeset/tall-rules-hammer.md
Normal file
5
.changeset/tall-rules-hammer.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/oauth-client": patch
|
||||
---
|
||||
|
||||
Minor code optimizations
|
5
.changeset/tiny-goats-sing.md
Normal file
5
.changeset/tiny-goats-sing.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto-labs/fetch": patch
|
||||
---
|
||||
|
||||
Remove explicit dependency on "zod". Improved typing of `fetchJsonZodProcessor` function.
|
5
.changeset/weak-elephants-thank.md
Normal file
5
.changeset/weak-elephants-thank.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/oauth-client-browser-example": patch
|
||||
---
|
||||
|
||||
Build using SWC
|
5
.changeset/young-parents-learn.md
Normal file
5
.changeset/young-parents-learn.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/pds": patch
|
||||
---
|
||||
|
||||
Add support for account sign-ups during OAuth flows
|
1
.github/workflows/repo.yaml
vendored
1
.github/workflows/repo.yaml
vendored
@ -35,6 +35,7 @@ jobs:
|
||||
path: |
|
||||
packages/*/dist
|
||||
packages/*/*/dist
|
||||
packages/oauth/oauth-provider/src/assets/app/locales/*/messages.ts
|
||||
retention-days: 1
|
||||
test:
|
||||
name: Test
|
||||
|
@ -12,3 +12,6 @@ packages/api/src/client
|
||||
packages/bsky/src/lexicon
|
||||
packages/pds/src/lexicon
|
||||
packages/ozone/src/lexicon
|
||||
|
||||
# Automatically generated by lingui
|
||||
packages/oauth/oauth-provider/src/assets/app/locales/*/messages.ts
|
||||
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -12,6 +12,7 @@
|
||||
"consolas",
|
||||
"dpop",
|
||||
"googleusercontent",
|
||||
"hcaptcha",
|
||||
"hexeditor",
|
||||
"ingester",
|
||||
"insertable",
|
||||
|
16
package.json
16
package.json
@ -11,7 +11,7 @@
|
||||
"packageManager": "pnpm@8.15.9",
|
||||
"scripts": {
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"lint": "eslint . --ext .ts,.js",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"style:fix": "prettier --write .",
|
||||
"style": "prettier --check .",
|
||||
"verify": "pnpm --stream '/^verify:.+$/'",
|
||||
@ -19,13 +19,13 @@
|
||||
"verify:lint": "pnpm lint",
|
||||
"verify:types": "tsc --build tsconfig.json",
|
||||
"format": "pnpm lint:fix && pnpm style:fix",
|
||||
"codegen": "pnpm run --recursive --stream --filter '@atproto/lex-cli...' build --force && pnpm run --recursive --stream --parallel codegen",
|
||||
"build": "pnpm --recursive --stream build",
|
||||
"dev": "NODE_ENV=development pnpm --stream '/^dev:.+$/'",
|
||||
"dev:tsc": "tsc --build tsconfig.json --watch",
|
||||
"dev:pkg": "pnpm --recursive --parallel --stream dev",
|
||||
"test": "LOG_ENABLED=false ./packages/dev-infra/with-test-redis-and-db.sh pnpm --stream -r test",
|
||||
"test:withFlags": "LOG_ENABLED=false ./packages/dev-infra/with-test-redis-and-db.sh pnpm --stream -r test --",
|
||||
"precodegen": "pnpm run --recursive --stream --filter '@atproto/lex-cli...' build --force",
|
||||
"codegen": "pnpm run --recursive --stream --parallel codegen",
|
||||
"build": "pnpm run --recursive --stream '/^(build|build:.+)$/'",
|
||||
"dev": "NODE_ENV=development pnpm run --recursive --parallel --stream '/^(dev|dev:.+)$/'",
|
||||
"dev:tsc": "tsc --build tsconfig.json --preserveWatchOutput --watch",
|
||||
"test": "LOG_ENABLED=false ./packages/dev-infra/with-test-redis-and-db.sh pnpm test --stream --recursive",
|
||||
"test:withFlags": "pnpm run test --",
|
||||
"changeset": "changeset",
|
||||
"release": "pnpm build && changeset publish",
|
||||
"version-packages": "changeset version && git add ."
|
||||
|
@ -37,7 +37,9 @@ export class TestPds {
|
||||
recoveryDidKey: recoveryKey,
|
||||
adminPassword: ADMIN_PASSWORD,
|
||||
jwtSecret: JWT_SECRET,
|
||||
serviceHandleDomains: ['.test'],
|
||||
// @NOTE ".example" will not actually work and is only used to display
|
||||
// multiple domains in the sing-up UI
|
||||
serviceHandleDomains: ['.test', '.example'],
|
||||
bskyAppViewUrl: 'https://appview.invalid',
|
||||
bskyAppViewDid: 'did:example:invalid',
|
||||
bskyAppViewCdnUrlPattern: 'http://cdn.appview.com/%s/%s/%s',
|
||||
@ -47,10 +49,15 @@ export class TestPds {
|
||||
inviteRequired: false,
|
||||
disableSsrfProtection: true,
|
||||
serviceName: 'Development PDS',
|
||||
brandColor: '#ffcb1e',
|
||||
errorColor: undefined,
|
||||
brandColor: '#8338ec',
|
||||
errorColor: '#ff006e',
|
||||
warningColor: '#fb5607',
|
||||
successColor: '#02c39a',
|
||||
logoUrl:
|
||||
'https://uxwing.com/wp-content/themes/uxwing/download/animals-and-birds/bee-icon.png',
|
||||
// Using a "data:" instead of a real URL to avoid making CORS requests in dev.
|
||||
// License: https://uxwing.com/license/
|
||||
// Source: https://uxwing.com/bee-icon/
|
||||
`data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 117.47 122.88"><defs><style>.cls-1,.cls-2{fill-rule:evenodd;}.cls-2{fill:#ffcb1e;}</style></defs><title>bee</title><path class="cls-1" d="M72.69,49.18c5.93.81,12.68,1.76,19.09,3.3,41.08,9.87,25.49,44.79,4.33,40.78A23.57,23.57,0,0,1,83.48,86.2c0,1.23-.12,2.45-.24,3.65a1.47,1.47,0,0,1-.07.72,49.21,49.21,0,0,1-6.78,20.68c-4.49,7.15-10.75,11.63-17.72,11.63S45.4,118.52,40.9,111.46a41.91,41.91,0,0,1-4-8.23l-.06-.18a54.7,54.7,0,0,1-3-17.22,24.75,24.75,0,0,1-13,7.43C.68,97.49-15.49,62.44,25.22,52.48A184,184,0,0,1,44.4,49.16l-.09-.09a9.18,9.18,0,0,1-1.9-2.74,28,28,0,0,1-2.26-10.81,17.15,17.15,0,0,1,5.41-12.45,18.57,18.57,0,0,1,7.78-4.42,19.21,19.21,0,0,0-2.19-7.07A8.05,8.05,0,0,0,47.4,8.13a4.77,4.77,0,1,1,1.38-3.36c0,.22,0,.43,0,.64a11,11,0,0,1,5,4.64,21.82,21.82,0,0,1,2.56,8,20.17,20.17,0,0,1,2.21-.13c.56,0,1.11,0,1.65.07a21.65,21.65,0,0,1,2.66-8.14,10.84,10.84,0,0,1,5.45-4.68c0-.14,0-.28,0-.42A4.77,4.77,0,1,1,69.49,8a7.8,7.8,0,0,0-4.07,3.48,18.73,18.73,0,0,0-2.26,7.06,18.57,18.57,0,0,1,8.31,4.56,17.11,17.11,0,0,1,5.41,12.45,27.65,27.65,0,0,1-2.6,11.38,10,10,0,0,1-1.59,2.28Z"/><path class="cls-2" d="M40.15,103.25a37.55,37.55,0,0,0,3.3,6.59c3.94,6.18,9.33,10,15.22,10s11.22-3.94,15.16-10.21A38.55,38.55,0,0,0,77,103.28q-9.49-5.66-18.7-5.66a33.91,33.91,0,0,0-18.17,5.63Zm31-37.85c-.65-1.51-1.29-3-1.92-4.42-1.84-4.19-3.37-7.81-5.18-12a21.24,21.24,0,0,0-5.76-.9,22,22,0,0,0-5.23.54C51.2,53,49.64,56.67,47.74,61l-1.89,4.36a41.7,41.7,0,0,1,12.58-2.09A37.6,37.6,0,0,1,71.17,65.4ZM69.39,25.26A15.7,15.7,0,0,0,58.52,21a15.89,15.89,0,0,0-3,.28,1.21,1.21,0,0,1-.33.07h0a15.56,15.56,0,0,0-7.47,3.93,14.13,14.13,0,0,0-4.46,10.26,24.67,24.67,0,0,0,2,9.51,6.21,6.21,0,0,0,1.24,1.83,1.43,1.43,0,0,0,1,.47,1.51,1.51,0,0,0,.64-.16,24,24,0,0,1,20.6.37,1.55,1.55,0,0,0,.73.2,1.57,1.57,0,0,0,1-.46,6.41,6.41,0,0,0,1.3-1.78,24.28,24.28,0,0,0,2.25-10,14.17,14.17,0,0,0-4.46-10.26Zm9.38,55c-1.86-2.83-4.59-10.46-7.11-11.45a35.4,35.4,0,0,0-13.21-2.54A40,40,0,0,0,45,68.82c-1.8.66-5.3,9.18-6.78,11.44-1.65,2.51-1.33,1.13-1.36,3.52,0,.12,0,.45,0,1s0,1.32,0,2c.29-.28.59-.55.89-.81C43.56,81,51.53,79.15,59.3,79.48S74.62,82.3,79.69,86.1l.73.58c0-.82.06-3.55.06-4.38,0-.39-.85-.74-1.71-2ZM37.19,90.9a50.67,50.67,0,0,0,1.94,9.42A36.54,36.54,0,0,1,58.32,94.6q9.78,0,19.73,5.78a52,52,0,0,0,2.08-9.86,17.62,17.62,0,0,0-2.26-2c-4.6-3.45-11.55-5.72-18.69-6S44.9,83.86,39.76,88.26a19.46,19.46,0,0,0-2.57,2.64Z"/></svg>', 'utf8').toString('base64')}`,
|
||||
homeUrl: 'https://bsky.social/',
|
||||
termsOfServiceUrl: 'https://bsky.social/about/support/tos',
|
||||
privacyPolicyUrl: 'https://bsky.social/about/support/privacy-policy',
|
||||
|
@ -28,9 +28,6 @@
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build tsconfig.json"
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import type { ParseParams, TypeOf, ZodTypeAny } from 'zod'
|
||||
import { Transformer, pipe } from '@atproto-labs/pipe'
|
||||
import { FetchError } from './fetch-error.js'
|
||||
import { TransformedResponse } from './transformed-response.js'
|
||||
@ -6,7 +5,6 @@ import {
|
||||
Json,
|
||||
MaxBytesTransformStream,
|
||||
cancelBody,
|
||||
ifObject,
|
||||
ifString,
|
||||
logCancellationError,
|
||||
} from './util.js'
|
||||
@ -69,15 +67,16 @@ const extractResponseMessage: ResponseMessageGetter = async (response) => {
|
||||
const json: unknown = await response.json()
|
||||
|
||||
if (typeof json === 'string') return json
|
||||
if (typeof json === 'object' && json != null) {
|
||||
const errorDescription = ifString(json['error_description'])
|
||||
if (errorDescription) return errorDescription
|
||||
|
||||
const errorDescription = ifString(ifObject(json)?.['error_description'])
|
||||
if (errorDescription) return errorDescription
|
||||
const error = ifString(json['error'])
|
||||
if (error) return error
|
||||
|
||||
const error = ifString(ifObject(json)?.['error'])
|
||||
if (error) return error
|
||||
|
||||
const message = ifString(ifObject(json)?.['message'])
|
||||
if (message) return message
|
||||
const message = ifString(json['message'])
|
||||
if (message) return message
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
@ -283,10 +282,31 @@ export function fetchJsonProcessor<T = Json>(
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchJsonZodProcessor<S extends ZodTypeAny>(
|
||||
schema: S,
|
||||
params?: Partial<ParseParams>,
|
||||
): Transformer<ParsedJsonResponse, TypeOf<S>> {
|
||||
return async (jsonResponse: ParsedJsonResponse): Promise<TypeOf<S>> =>
|
||||
schema.parseAsync(jsonResponse.json, params)
|
||||
export type SyncValidationSchema<S, P = unknown> = {
|
||||
parse(value: unknown, params?: P): S
|
||||
}
|
||||
|
||||
export type AsyncValidationSchema<S, P = unknown> = {
|
||||
parseAsync(value: unknown, params?: P): Promise<S>
|
||||
}
|
||||
|
||||
export function fetchJsonValidatorProcessor<S, P = unknown>(
|
||||
schema: SyncValidationSchema<S, P> | AsyncValidationSchema<S, P>,
|
||||
params?: P,
|
||||
): Transformer<ParsedJsonResponse, S> {
|
||||
if ('parseAsync' in schema && typeof schema.parseAsync === 'function') {
|
||||
return async (jsonResponse: ParsedJsonResponse): Promise<S> =>
|
||||
schema.parseAsync(jsonResponse.json, params)
|
||||
}
|
||||
|
||||
if ('parse' in schema && typeof schema.parse === 'function') {
|
||||
return async (jsonResponse: ParsedJsonResponse): Promise<S> =>
|
||||
schema.parse(jsonResponse.json, params)
|
||||
}
|
||||
|
||||
// Needed for type safety (and allows fool proofing the usage of this function)
|
||||
throw new TypeError('Invalid schema')
|
||||
}
|
||||
|
||||
/** @note Use {@link fetchJsonValidatorProcessor} instead */
|
||||
export const fetchJsonZodProcessor = fetchJsonValidatorProcessor
|
||||
|
@ -24,24 +24,6 @@ export function isIp(hostname: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
const plainObjectProto = Object.prototype
|
||||
export const ifObject = <V>(v: V) => {
|
||||
if (typeof v === 'object' && v != null && !Array.isArray(v)) {
|
||||
const proto = Object.getPrototypeOf(v)
|
||||
if (proto === null || proto === plainObjectProto) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return v as V extends JsonScalar | JsonArray | Function | symbol
|
||||
? never
|
||||
: V extends Json
|
||||
? V
|
||||
: // Plain object are (mostly) safe to access using a string index
|
||||
Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const ifString = <V>(v: V) => (typeof v === 'string' ? v : undefined)
|
||||
|
||||
export class MaxBytesTransformStream extends TransformStream<
|
||||
|
@ -32,7 +32,7 @@ export default function bundleManifest({
|
||||
}: {
|
||||
name?: string
|
||||
data?: boolean
|
||||
} = {}): Plugin {
|
||||
} = {}): Plugin<never> {
|
||||
return {
|
||||
name: 'bundle-manifest',
|
||||
generateBundle(outputOptions, bundle) {
|
||||
|
@ -129,7 +129,7 @@ export const jwtPayloadSchema = z
|
||||
.optional(),
|
||||
locale: z
|
||||
.string()
|
||||
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
|
||||
.regex(/^[a-z]{2,3}(-[A-Z]{2})?$/)
|
||||
.optional(),
|
||||
updated_at: z.number().int().optional(),
|
||||
|
||||
|
@ -38,15 +38,14 @@
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-html": "^1.0.4",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-replace": "^5.0.5",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"@types/react": "^18.2.50",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@rollup/plugin-swc": "^0.4.0",
|
||||
"@swc/helpers": "^0.5.15",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"rollup": "^4.13.0",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
"rollup-plugin-serve": "^1.1.1",
|
||||
|
@ -4,9 +4,7 @@ const { default: commonjs } = require('@rollup/plugin-commonjs')
|
||||
const { default: html, makeHtmlAttributes } = require('@rollup/plugin-html')
|
||||
const { default: json } = require('@rollup/plugin-json')
|
||||
const { default: nodeResolve } = require('@rollup/plugin-node-resolve')
|
||||
const { default: replace } = require('@rollup/plugin-replace')
|
||||
const { default: terser } = require('@rollup/plugin-terser')
|
||||
const { default: typescript } = require('@rollup/plugin-typescript')
|
||||
const { default: swc } = require('@rollup/plugin-swc')
|
||||
const { defineConfig } = require('rollup')
|
||||
const {
|
||||
default: manifest,
|
||||
@ -19,7 +17,7 @@ module.exports = defineConfig((commandLineArguments) => {
|
||||
process.env['NODE_ENV'] ??
|
||||
(commandLineArguments.watch ? 'development' : 'production')
|
||||
|
||||
const minify = NODE_ENV !== 'development'
|
||||
const devMode = NODE_ENV === 'development'
|
||||
|
||||
return {
|
||||
input: 'src/main.tsx',
|
||||
@ -30,17 +28,46 @@ module.exports = defineConfig((commandLineArguments) => {
|
||||
format: 'iife',
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'resolve-swc-helpers',
|
||||
resolveId(src) {
|
||||
// For some reason, "nodeResolve" doesn't resolve these:
|
||||
if (src.startsWith('@swc/helpers/')) return require.resolve(src)
|
||||
},
|
||||
},
|
||||
nodeResolve({ preferBuiltins: false, browser: true }),
|
||||
commonjs(),
|
||||
json(),
|
||||
postcss({ config: true, extract: true, minimize: false }),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.build.json',
|
||||
outputToFilesystem: true,
|
||||
}),
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
values: { 'process.env.NODE_ENV': JSON.stringify(NODE_ENV) },
|
||||
swc({
|
||||
swc: {
|
||||
swcrc: false,
|
||||
configFile: false,
|
||||
sourceMaps: true,
|
||||
minify: !devMode,
|
||||
jsc: {
|
||||
minify: {
|
||||
compress: {
|
||||
module: true,
|
||||
unused: true,
|
||||
},
|
||||
mangle: true,
|
||||
},
|
||||
externalHelpers: true,
|
||||
target: 'es2020',
|
||||
parser: { syntax: 'typescript', tsx: true },
|
||||
transform: {
|
||||
useDefineForClassFields: true,
|
||||
react: { runtime: 'automatic' },
|
||||
optimizer: {
|
||||
simplify: true,
|
||||
globals: {
|
||||
vars: { 'process.env.NODE_ENV': JSON.stringify(NODE_ENV) },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
html({
|
||||
title: 'OAuth Client Example',
|
||||
@ -79,7 +106,6 @@ module.exports = defineConfig((commandLineArguments) => {
|
||||
</html>
|
||||
`,
|
||||
}),
|
||||
minify && terser({}),
|
||||
manifest({ name: 'files.json', data: true }),
|
||||
|
||||
commandLineArguments.watch &&
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useAuthContext } from './auth/auth-provider'
|
||||
import { OAuthSession } from '@atproto/oauth-client'
|
||||
import { useAuthContext } from './auth/auth-provider.tsx'
|
||||
|
||||
function App() {
|
||||
const { pdsAgent, signOut, refresh } = useAuthContext()
|
||||
@ -18,7 +18,7 @@ function App() {
|
||||
const [serviceAuth, setServiceAuth] = useState<unknown>(undefined)
|
||||
const loadServiceAuth = useCallback(async () => {
|
||||
const serviceAuth = await pdsAgent.com.atproto.server.getServiceAuth({
|
||||
aud: pdsAgent.accountDid,
|
||||
aud: pdsAgent.assertDid,
|
||||
})
|
||||
console.log('serviceAuth', serviceAuth)
|
||||
setServiceAuth(serviceAuth.data)
|
||||
@ -28,7 +28,7 @@ function App() {
|
||||
const [profile, setProfile] = useState<unknown>(undefined)
|
||||
const loadProfile = useCallback(async () => {
|
||||
const profile = await pdsAgent.com.atproto.repo.getRecord({
|
||||
repo: pdsAgent.accountDid,
|
||||
repo: pdsAgent.assertDid,
|
||||
collection: 'app.bsky.actor.profile',
|
||||
rkey: 'self',
|
||||
})
|
||||
|
@ -1,18 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
AtpSignIn,
|
||||
CredentialSignInForm,
|
||||
} from './credential/credential-sign-in-form'
|
||||
import { OAuthSignIn, OAuthSignInForm } from './oauth/oauth-sign-in-form'
|
||||
} from './credential/credential-sign-in-form.tsx'
|
||||
import { OAuthSignIn, OAuthSignInForm } from './oauth/oauth-sign-in-form.tsx'
|
||||
|
||||
export function AuthForm({
|
||||
atpSignIn,
|
||||
oauthSignIn,
|
||||
}: {
|
||||
export type AuthFormProps = {
|
||||
atpSignIn?: AtpSignIn
|
||||
oauthSignIn?: OAuthSignIn
|
||||
}) {
|
||||
signUpUrl?: string
|
||||
}
|
||||
|
||||
export function AuthForm({ atpSignIn, oauthSignIn, signUpUrl }: AuthFormProps) {
|
||||
const defaultMethod = oauthSignIn
|
||||
? 'oauth'
|
||||
: atpSignIn
|
||||
@ -58,7 +57,9 @@ export function AuthForm({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{method === 'oauth' && <OAuthSignInForm signIn={oauthSignIn!} />}
|
||||
{method === 'oauth' && (
|
||||
<OAuthSignInForm signIn={oauthSignIn!} signUpUrl={signUpUrl} />
|
||||
)}
|
||||
{method === 'credential' && <CredentialSignInForm signIn={atpSignIn!} />}
|
||||
{method == null && <div>No auth method available</div>}
|
||||
</div>
|
||||
|
@ -1,26 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, createContext, useContext, useMemo } from 'react'
|
||||
import { Agent } from '@atproto/api'
|
||||
import { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||
import { AuthForm } from './auth-form.tsx'
|
||||
import { useCredentialAuth } from './credential/use-credential-auth.ts'
|
||||
import { UseOAuthOptions, useOAuth } from './oauth/use-oauth.ts'
|
||||
|
||||
import { useCredentialAuth } from './credential/use-credential-auth'
|
||||
import { AuthForm } from './auth-form'
|
||||
import { useOAuth, UseOAuthOptions } from './oauth/use-oauth'
|
||||
|
||||
export type AuthContext = {
|
||||
export type AuthContextValue = {
|
||||
pdsAgent: Agent
|
||||
signOut: () => void
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContext | null>(null)
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export type AuthProviderProps = UseOAuthOptions & {
|
||||
children: ReactNode
|
||||
signUpUrl?: string
|
||||
}
|
||||
|
||||
export const AuthProvider = ({
|
||||
children,
|
||||
signUpUrl,
|
||||
|
||||
// UseOAuthOptions
|
||||
...options
|
||||
}: {
|
||||
children: ReactNode
|
||||
} & UseOAuthOptions) => {
|
||||
}: AuthProviderProps) => {
|
||||
const {
|
||||
isLoginPopup,
|
||||
isInitializing,
|
||||
@ -38,7 +43,7 @@ export const AuthProvider = ({
|
||||
refresh: credentialRefresh,
|
||||
} = useCredentialAuth()
|
||||
|
||||
const value = useMemo<AuthContext | null>(() => {
|
||||
const value = useMemo<AuthContextValue | null>(() => {
|
||||
if (oauthAgent) {
|
||||
return {
|
||||
pdsAgent: oauthAgent,
|
||||
@ -77,6 +82,7 @@ export const AuthProvider = ({
|
||||
return (
|
||||
<AuthForm
|
||||
atpSignIn={credentialSignIn}
|
||||
signUpUrl={signUpUrl}
|
||||
oauthSignIn={oauthClient ? oauthSignIn : undefined}
|
||||
/>
|
||||
)
|
||||
@ -85,7 +91,7 @@ export const AuthProvider = ({
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuthContext(): AuthContext {
|
||||
export function useAuthContext(): AuthContextValue {
|
||||
const context = useContext(AuthContext)
|
||||
if (context) return context
|
||||
|
||||
|
@ -1,59 +1,69 @@
|
||||
import { FormEvent, JSX, useState } from 'react'
|
||||
import { AuthorizeOptions } from '@atproto/oauth-client-browser'
|
||||
import { FormEvent, useCallback, useState } from 'react'
|
||||
|
||||
export type OAuthSignIn = (input: string, options?: AuthorizeOptions) => unknown
|
||||
|
||||
export type OAuthSignInFormProps = Omit<
|
||||
JSX.IntrinsicElements['form'],
|
||||
'onSubmit'
|
||||
> & {
|
||||
signIn: OAuthSignIn
|
||||
signUpUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Nice tailwind css form asking to enter either a handle or the host
|
||||
* to use to login.
|
||||
*/
|
||||
export function OAuthSignInForm({
|
||||
signIn,
|
||||
signUpUrl,
|
||||
|
||||
// form
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
signIn: OAuthSignIn
|
||||
} & Omit<React.HTMLAttributes<HTMLFormElement>, 'onSubmit'>) {
|
||||
}: OAuthSignInFormProps) {
|
||||
const [value, setValue] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (loading) return
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
if (loading) return
|
||||
if (!event.currentTarget.reportValidity()) return
|
||||
|
||||
try {
|
||||
if (value.startsWith('did:')) {
|
||||
if (value.length > 5) await signIn(value)
|
||||
else setError('DID must be at least 6 characters')
|
||||
} else if (
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('http://')
|
||||
) {
|
||||
const url = new URL(value)
|
||||
if (value !== url.origin) throw new Error('PDS URL must be a origin')
|
||||
await signIn(value)
|
||||
} else if (value.includes('.') && value.length > 3) {
|
||||
const handle = value.startsWith('@') ? value.slice(1) : value
|
||||
if (handle.length > 3) await signIn(handle)
|
||||
else setError('Handle must be at least 4 characters')
|
||||
}
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
throw new Error('Please provide a valid handle, DID or PDS URL')
|
||||
} catch (err) {
|
||||
setError((err as any)?.message || String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
try {
|
||||
if (value.startsWith('did:')) {
|
||||
if (value.length > 5) await signIn(value)
|
||||
else setError('DID must be at least 6 characters')
|
||||
} else if (value.startsWith('https://') || value.startsWith('http://')) {
|
||||
const url = new URL(value)
|
||||
if (value !== url.origin) throw new Error('PDS URL must be a origin')
|
||||
await signIn(value)
|
||||
} else if (value.includes('.') && value.length > 3) {
|
||||
const handle = value.startsWith('@') ? value.slice(1) : value
|
||||
if (handle.length > 3) await signIn(handle)
|
||||
else setError('Handle must be at least 4 characters')
|
||||
}
|
||||
},
|
||||
[loading, value, signIn],
|
||||
)
|
||||
|
||||
throw new Error('Please provide a valid handle, DID or PDS URL')
|
||||
} catch (err) {
|
||||
setError((err as any)?.message || String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form {...props} className="max-w-lg w-full" onSubmit={onSubmit}>
|
||||
<form
|
||||
{...props}
|
||||
className={`${className || ''} max-w-lg w-full`}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<fieldset className="rounded-md border border-solid border-slate-200 dark:border-slate-700 text-neutral-700 dark:text-neutral-100">
|
||||
<div className="relative p-1 flex flex-wrap items-center justify-stretch">
|
||||
<input
|
||||
@ -78,6 +88,17 @@ export function OAuthSignInForm({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{signUpUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn(signUpUrl)}
|
||||
disabled={loading}
|
||||
className="mt-2 bg-blue-600 text-white rounded-md py-1 px-3 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:ring-inset"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error ? <div className="alert alert-error">{error}</div> : null}
|
||||
</form>
|
||||
)
|
||||
|
@ -151,7 +151,7 @@ export function useOAuth(options: UseOAuthOptions) {
|
||||
const [isInitializing, setIsInitializing] = useState(true)
|
||||
const [isLoginPopup, setIsLoginPopup] = useState(false)
|
||||
|
||||
const clientForInitRef = useRef<typeof clientForInit>()
|
||||
const clientForInitRef = useRef<typeof clientForInit>(null)
|
||||
useEffect(() => {
|
||||
// In strict mode, we don't want to re-init() the client if it's the same
|
||||
if (clientForInitRef.current === clientForInit) return
|
||||
|
@ -12,3 +12,7 @@ export const PLC_DIRECTORY_URL: string | undefined =
|
||||
export const HANDLE_RESOLVER_URL: string =
|
||||
searchParams.get('handle_resolver') ??
|
||||
(ENV === 'development' ? 'http://localhost:2584' : 'https://bsky.social')
|
||||
|
||||
export const SIGN_UP_URL: string =
|
||||
searchParams.get('sign_up_url') ??
|
||||
(ENV === 'development' ? 'http://localhost:2583' : 'https://bsky.social')
|
||||
|
@ -1,11 +1,15 @@
|
||||
import './index.css'
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import App from './app'
|
||||
import { AuthProvider } from './auth/auth-provider'
|
||||
import { ENV, HANDLE_RESOLVER_URL, PLC_DIRECTORY_URL } from './constants'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './app.tsx'
|
||||
import { AuthProvider } from './auth/auth-provider.tsx'
|
||||
import {
|
||||
ENV,
|
||||
HANDLE_RESOLVER_URL,
|
||||
PLC_DIRECTORY_URL,
|
||||
SIGN_UP_URL,
|
||||
} from './constants.ts'
|
||||
|
||||
const clientId = `http://localhost?${new URLSearchParams({
|
||||
scope: 'atproto transition:generic',
|
||||
@ -14,20 +18,22 @@ const clientId = `http://localhost?${new URLSearchParams({
|
||||
search: new URLSearchParams({
|
||||
env: ENV,
|
||||
handle_resolver: HANDLE_RESOLVER_URL,
|
||||
sign_up_url: SIGN_UP_URL,
|
||||
...(PLC_DIRECTORY_URL && { plc_directory_url: PLC_DIRECTORY_URL }),
|
||||
}).toString(),
|
||||
}).href,
|
||||
})}`
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AuthProvider
|
||||
clientId={clientId}
|
||||
plcDirectoryUrl={PLC_DIRECTORY_URL}
|
||||
signUpUrl={SIGN_UP_URL}
|
||||
handleResolver={HANDLE_RESOLVER_URL}
|
||||
allowHttp={ENV === 'development' || ENV === 'test'}
|
||||
>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
</StrictMode>,
|
||||
)
|
||||
|
@ -5,4 +5,6 @@ export default {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
// See rollup.config.js for classes used in the HTML template
|
||||
safelist: ['bg-white', 'dark:bg-slate-800'],
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Json, ifObject, ifString } from '@atproto-labs/fetch'
|
||||
import { Json } from '@atproto-labs/fetch'
|
||||
import { ifString } from './util.js'
|
||||
|
||||
export class OAuthResponseError extends Error {
|
||||
readonly error?: string
|
||||
@ -8,8 +9,9 @@ export class OAuthResponseError extends Error {
|
||||
public readonly response: Response,
|
||||
public readonly payload: Json,
|
||||
) {
|
||||
const error = ifString(ifObject(payload)?.['error'])
|
||||
const errorDescription = ifString(ifObject(payload)?.['error_description'])
|
||||
const objPayload = typeof payload === 'object' ? payload : undefined
|
||||
const error = ifString(objPayload?.['error'])
|
||||
const errorDescription = ifString(objPayload?.['error_description'])
|
||||
|
||||
const messageError = error ? `"${error}"` : 'unknown'
|
||||
const messageDesc = errorDescription ? `: ${errorDescription}` : ''
|
||||
|
@ -4,6 +4,8 @@ export type Simplify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown>
|
||||
// @ts-expect-error
|
||||
Symbol.dispose ??= Symbol('@@dispose')
|
||||
|
||||
export const ifString = <V>(v: V) => (typeof v === 'string' ? v : undefined)
|
||||
|
||||
/**
|
||||
* @todo (?) move to common package
|
||||
*/
|
||||
|
2
packages/oauth/oauth-provider/.gitignore
vendored
Normal file
2
packages/oauth/oauth-provider/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
src/assets/app/locales/*/*.ts
|
||||
.swc
|
57
packages/oauth/oauth-provider/.linguirc
Normal file
57
packages/oauth/oauth-provider/.linguirc
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"format": "po",
|
||||
"sourceLocale": "en",
|
||||
"locales": [
|
||||
"en",
|
||||
"an",
|
||||
"ast",
|
||||
"ca",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en-GB",
|
||||
"es",
|
||||
"eu",
|
||||
"fi",
|
||||
"fr",
|
||||
"ga",
|
||||
"gl",
|
||||
"hi",
|
||||
"hu",
|
||||
"ia",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"km",
|
||||
"ko",
|
||||
"ne",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-BR",
|
||||
"ro",
|
||||
"ru",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-CN",
|
||||
"zh-HK",
|
||||
"zh-TW"
|
||||
],
|
||||
"fallbackLocales": {
|
||||
"default": "en"
|
||||
},
|
||||
"catalogs": [
|
||||
{
|
||||
"path": "<rootDir>/src/assets/app/locales/{locale}/messages",
|
||||
"include": [
|
||||
"<rootDir>/src/assets/app"
|
||||
],
|
||||
"exclude": [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
plugins:
|
||||
tailwindcss: {}
|
||||
autoprefixer: {}
|
@ -41,9 +41,11 @@
|
||||
"@atproto/jwk-jose": "workspace:*",
|
||||
"@atproto/oauth-types": "workspace:*",
|
||||
"@hapi/accept": "^6.0.3",
|
||||
"@hapi/address": "^5.1.1",
|
||||
"@hapi/bourne": "^3.0.0",
|
||||
"@hapi/content": "^6.0.0",
|
||||
"cookie": "^0.6.0",
|
||||
"disposable-email-domains-js": "^1.5.0",
|
||||
"forwarded": "^0.2.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"ioredis": "^5.3.2",
|
||||
@ -53,31 +55,52 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto-labs/rollup-plugin-bundle-manifest": "workspace:*",
|
||||
"@rollup/plugin-commonjs": "^25.0.7",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-replace": "^5.0.5",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^11.1.6",
|
||||
"@hcaptcha/react-hcaptcha": "^1.11.2",
|
||||
"@lingui/cli": "^5.2.0",
|
||||
"@lingui/core": "^5.2.0",
|
||||
"@lingui/react": "^5.2.0",
|
||||
"@lingui/swc-plugin": "^5.4.0",
|
||||
"@lingui/vite-plugin": "^5.2.0",
|
||||
"@rollup/plugin-commonjs": "^28.0.2",
|
||||
"@rollup/plugin-dynamic-import-vars": "^2.1.5",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@rollup/plugin-swc": "^0.4.0",
|
||||
"@swc/core": "^1.10.18",
|
||||
"@swc/helpers": "^0.5.15",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/forwarded": "0.1.3",
|
||||
"@types/psl": "1.1.3",
|
||||
"@types/react": "^18.2.50",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/send": "^0.17.4",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"@web/rollup-plugin-import-meta-assets": "^2.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"rollup": "^4.13.0",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.6.3"
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"po:extract": "lingui extract --clean",
|
||||
"po:compile": "lingui compile --typescript",
|
||||
"prebuild:frontend": "pnpm po:compile",
|
||||
"build:frontend": "rollup --config rollup.config.js",
|
||||
"build:backend": "tsc --build --force tsconfig.backend.json",
|
||||
"build": "pnpm --parallel --stream '/^build:.+$/'",
|
||||
"dev": "rollup --config rollup.config.js --watch"
|
||||
"start:ui": "vite",
|
||||
"dev:frontend": "pnpm run build:frontend --watch",
|
||||
"dev:catalogs": "pnpm run po:extract --debounce 250 --watch > /dev/null",
|
||||
"dev:messages": "pnpm run po:compile --debounce 500 --watch"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"tailwindcss": {},
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
/* eslint-env node */
|
||||
|
||||
const { default: commonjs } = require('@rollup/plugin-commonjs')
|
||||
const {
|
||||
default: dynamicImportVars,
|
||||
} = require('@rollup/plugin-dynamic-import-vars')
|
||||
const { default: nodeResolve } = require('@rollup/plugin-node-resolve')
|
||||
const { default: replace } = require('@rollup/plugin-replace')
|
||||
const { default: terser } = require('@rollup/plugin-terser')
|
||||
const { default: typescript } = require('@rollup/plugin-typescript')
|
||||
const { default: swc } = require('@rollup/plugin-swc')
|
||||
const { defineConfig } = require('rollup')
|
||||
const {
|
||||
default: manifest,
|
||||
@ -16,34 +17,77 @@ module.exports = defineConfig((commandLineArguments) => {
|
||||
process.env['NODE_ENV'] ??
|
||||
(commandLineArguments.watch ? 'development' : 'production')
|
||||
|
||||
const minify = NODE_ENV !== 'development'
|
||||
const devMode = NODE_ENV === 'development'
|
||||
|
||||
return {
|
||||
input: 'src/assets/app/main.tsx',
|
||||
input: ['src/assets/app/main.tsx', 'src/assets/app/main.css'],
|
||||
output: {
|
||||
manualChunks: undefined,
|
||||
sourcemap: true,
|
||||
file: 'dist/assets/app/main.js',
|
||||
format: 'iife',
|
||||
dir: 'dist/assets/app',
|
||||
format: 'module',
|
||||
entryFileNames: 'main-[hash].js',
|
||||
},
|
||||
plugins: [
|
||||
nodeResolve({ preferBuiltins: false, browser: true }),
|
||||
{
|
||||
name: 'resolve-swc-helpers',
|
||||
resolveId(src) {
|
||||
// For some reason, "nodeResolve" doesn't resolve these:
|
||||
if (src.startsWith('@swc/helpers/')) return require.resolve(src)
|
||||
},
|
||||
},
|
||||
nodeResolve({
|
||||
preferBuiltins: false,
|
||||
browser: true,
|
||||
exportConditions: ['browser', 'module', 'import', 'default'],
|
||||
}),
|
||||
commonjs(),
|
||||
postcss({ config: true, extract: true, minimize: minify }),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.frontend.json',
|
||||
outputToFilesystem: true,
|
||||
}),
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
values: { 'process.env.NODE_ENV': JSON.stringify(NODE_ENV) },
|
||||
postcss({ config: true, extract: true, minimize: !devMode }),
|
||||
swc({
|
||||
swc: {
|
||||
swcrc: false,
|
||||
configFile: false,
|
||||
sourceMaps: true,
|
||||
minify: !devMode,
|
||||
jsc: {
|
||||
experimental: {
|
||||
// @NOTE Because of the experimental nature of SWC plugins, A
|
||||
// very particular version of @swc/core needs to be used. The
|
||||
// link below allows to determine with version of @swc/core is
|
||||
// compatible based on the version of @lingui/swc-plugin used
|
||||
// (click on the swc_core version in the right column to see
|
||||
// which version of the @swc/core is compatible)
|
||||
//
|
||||
// https://github.com/lingui/swc-plugin?tab=readme-ov-file#compatibility
|
||||
plugins: [['@lingui/swc-plugin', {}]],
|
||||
},
|
||||
minify: {
|
||||
compress: true,
|
||||
mangle: true,
|
||||
},
|
||||
externalHelpers: true,
|
||||
target: 'es2020',
|
||||
parser: { syntax: 'typescript', tsx: true },
|
||||
transform: {
|
||||
useDefineForClassFields: true,
|
||||
react: { runtime: 'automatic' },
|
||||
optimizer: {
|
||||
simplify: true,
|
||||
globals: {
|
||||
vars: { 'process.env.NODE_ENV': JSON.stringify(NODE_ENV) },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
dynamicImportVars({ errorWhenNoFilesFound: true }),
|
||||
|
||||
// Change `data` to `true` to include assets data in the manifest,
|
||||
// allowing for easier bundling of the backend code (eg. using esbuild) as
|
||||
// bundlers know how to bundle JSON files but not how to bundle assets
|
||||
// referenced at runtime.
|
||||
manifest({ data: false }),
|
||||
minify && terser({}),
|
||||
],
|
||||
onwarn(warning, warn) {
|
||||
// 'use client' directives are fine
|
||||
|
@ -1,31 +1,164 @@
|
||||
import { isOAuthClientIdLoopback } from '@atproto/oauth-types'
|
||||
import {
|
||||
OAuthIssuerIdentifier,
|
||||
isOAuthClientIdLoopback,
|
||||
} from '@atproto/oauth-types'
|
||||
import { Client } from '../client/client.js'
|
||||
import { DeviceId } from '../device/device-id.js'
|
||||
import { InvalidRequestError } from '../errors/invalid-request-error.js'
|
||||
import { HCaptchaClient, HcaptchaVerifyResult } from '../lib/hcaptcha.js'
|
||||
import { callAsync } from '../lib/util/function.js'
|
||||
import { constantTime } from '../lib/util/time.js'
|
||||
import { InvalidRequestError } from '../oauth-errors.js'
|
||||
import { OAuthHooks, RequestMetadata } from '../oauth-hooks.js'
|
||||
import { Customization } from '../oauth-provider.js'
|
||||
import { Sub } from '../oidc/sub.js'
|
||||
import { ClientAuth } from '../token/token-store.js'
|
||||
import {
|
||||
Account,
|
||||
AccountInfo,
|
||||
AccountStore,
|
||||
SignInCredentials,
|
||||
ResetPasswordConfirmData,
|
||||
ResetPasswordRequestData,
|
||||
} from './account-store.js'
|
||||
import { SignInData } from './sign-in-data.js'
|
||||
import { SignUpData } from './sign-up-data.js'
|
||||
|
||||
const TIMING_ATTACK_MITIGATION_DELAY = 400
|
||||
const BRUTE_FORCE_MITIGATION_DELAY = 300
|
||||
|
||||
export class AccountManager {
|
||||
constructor(protected readonly store: AccountStore) {}
|
||||
protected readonly inviteCodeRequired: boolean
|
||||
protected readonly hcaptchaClient?: HCaptchaClient
|
||||
|
||||
constructor(
|
||||
issuer: OAuthIssuerIdentifier,
|
||||
protected readonly store: AccountStore,
|
||||
protected readonly hooks: OAuthHooks,
|
||||
customization: Customization,
|
||||
) {
|
||||
this.inviteCodeRequired = customization.inviteCodeRequired !== false
|
||||
this.hcaptchaClient = customization.hcaptcha
|
||||
? new HCaptchaClient(new URL(issuer).hostname, customization.hcaptcha)
|
||||
: undefined
|
||||
}
|
||||
|
||||
protected async verifySignupData(
|
||||
data: SignUpData,
|
||||
deviceId: DeviceId,
|
||||
deviceMetadata: RequestMetadata,
|
||||
): Promise<void> {
|
||||
let hcaptchaResult: undefined | HcaptchaVerifyResult
|
||||
|
||||
if (this.inviteCodeRequired && !data.inviteCode) {
|
||||
throw new InvalidRequestError('Invite code is required')
|
||||
}
|
||||
|
||||
if (this.hcaptchaClient) {
|
||||
if (!data.hcaptchaToken) {
|
||||
throw new InvalidRequestError('hCaptcha token is required')
|
||||
}
|
||||
|
||||
const { allowed, result } = await this.hcaptchaClient.verify(
|
||||
'signup',
|
||||
data.hcaptchaToken,
|
||||
deviceMetadata.ipAddress,
|
||||
data.handle,
|
||||
deviceMetadata.userAgent,
|
||||
)
|
||||
|
||||
await callAsync(this.hooks.onSignupHcaptchaResult, {
|
||||
data,
|
||||
allowed,
|
||||
result,
|
||||
deviceId,
|
||||
deviceMetadata,
|
||||
})
|
||||
|
||||
if (!allowed) {
|
||||
throw new InvalidRequestError('hCaptcha verification failed')
|
||||
}
|
||||
|
||||
hcaptchaResult = result
|
||||
}
|
||||
|
||||
await callAsync(this.hooks.onSignupAttempt, {
|
||||
data,
|
||||
deviceId,
|
||||
deviceMetadata,
|
||||
hcaptchaResult,
|
||||
})
|
||||
}
|
||||
|
||||
public async signUp(
|
||||
data: SignUpData,
|
||||
deviceId: DeviceId,
|
||||
deviceMetadata: RequestMetadata,
|
||||
): Promise<AccountInfo> {
|
||||
await this.verifySignupData(data, deviceId, deviceMetadata)
|
||||
|
||||
// Mitigation against brute forcing email of users.
|
||||
// @TODO Add rate limit to all the OAuth routes.
|
||||
return constantTime(BRUTE_FORCE_MITIGATION_DELAY, async () => {
|
||||
let account: Account
|
||||
try {
|
||||
account = await this.store.createAccount(data)
|
||||
} catch (err) {
|
||||
throw InvalidRequestError.from(err, 'Account creation failed')
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await this.store.addDeviceAccount(
|
||||
deviceId,
|
||||
account.sub,
|
||||
false,
|
||||
)
|
||||
|
||||
await callAsync(this.hooks.onSignedUp, {
|
||||
data,
|
||||
info,
|
||||
account,
|
||||
deviceId,
|
||||
deviceMetadata,
|
||||
})
|
||||
|
||||
return { account, info }
|
||||
} catch (err) {
|
||||
throw InvalidRequestError.from(
|
||||
err,
|
||||
'Something went wrong, try singing-in',
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async signIn(
|
||||
credentials: SignInCredentials,
|
||||
data: SignInData,
|
||||
deviceId: DeviceId,
|
||||
deviceMetadata: RequestMetadata,
|
||||
): Promise<AccountInfo> {
|
||||
return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
|
||||
const result = await this.store.authenticateAccount(credentials, deviceId)
|
||||
if (result) return result
|
||||
try {
|
||||
const account = await this.store.authenticateAccount(data)
|
||||
const info = await this.store.addDeviceAccount(
|
||||
deviceId,
|
||||
account.sub,
|
||||
data.remember,
|
||||
)
|
||||
|
||||
throw new InvalidRequestError('Invalid credentials')
|
||||
await callAsync(this.hooks.onSignedIn, {
|
||||
data,
|
||||
info,
|
||||
account,
|
||||
deviceId,
|
||||
deviceMetadata,
|
||||
})
|
||||
|
||||
return { account, info }
|
||||
} catch (err) {
|
||||
throw InvalidRequestError.from(
|
||||
err,
|
||||
'Unable to sign-in due to an unexpected server error',
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -52,4 +185,22 @@ export class AccountManager {
|
||||
const results = await this.store.listDeviceAccounts(deviceId)
|
||||
return results.filter((result) => result.info.remembered)
|
||||
}
|
||||
|
||||
public async resetPasswordRequest(data: ResetPasswordRequestData) {
|
||||
return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
|
||||
await this.store.resetPasswordRequest(data)
|
||||
})
|
||||
}
|
||||
|
||||
public async resetPasswordConfirm(data: ResetPasswordConfirmData) {
|
||||
return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
|
||||
await this.store.resetPasswordConfirm(data)
|
||||
})
|
||||
}
|
||||
|
||||
public async verifyHandleAvailability(handle: string): Promise<void> {
|
||||
return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
|
||||
return this.store.verifyHandleAvailability(handle)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -1,25 +1,86 @@
|
||||
import { isEmailValid } from '@hapi/address'
|
||||
import { isDisposableEmail } from 'disposable-email-domains-js'
|
||||
import { z } from 'zod'
|
||||
import { ClientId } from '../client/client-id.js'
|
||||
import { DeviceId } from '../device/device-id.js'
|
||||
import { Awaitable } from '../lib/util/type.js'
|
||||
import { localeSchema } from '../lib/locale.js'
|
||||
import { Awaitable, buildInterfaceChecker } from '../lib/util/type.js'
|
||||
import {
|
||||
HandleUnavailableError,
|
||||
InvalidRequestError,
|
||||
SecondAuthenticationFactorRequiredError,
|
||||
} from '../oauth-errors.js'
|
||||
import { Sub } from '../oidc/sub.js'
|
||||
import { Account } from './account.js'
|
||||
|
||||
export const signInCredentialsSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
// @NOTE Change the length here to force stronger passwords (through a reset)
|
||||
export const oldPasswordSchema = z.string().min(1)
|
||||
export const newPasswordSchema = z.string().min(8)
|
||||
export const tokenSchema = z.string().regex(/^[A-Z2-7]{5}-[A-Z2-7]{5}$/)
|
||||
export const handleSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(30)
|
||||
.regex(/^[a-z0-9][a-z0-9-]+[a-z0-9](?:\.[a-z0-9][a-z0-9-]+[a-z0-9])+$/)
|
||||
export const emailSchema = z
|
||||
.string()
|
||||
.email()
|
||||
// @NOTE using @hapi/address here, in addition to the email() check to ensure
|
||||
// compatibility with the current email validation in the PDS's account
|
||||
// manager
|
||||
.refine(isEmailValid, {
|
||||
message: 'Invalid email address',
|
||||
})
|
||||
.refine((email) => !isDisposableEmail(email), {
|
||||
message: 'Disposable email addresses are not allowed',
|
||||
})
|
||||
|
||||
/**
|
||||
* If false, the account must not be returned from
|
||||
* {@link AccountStore.listDeviceAccounts}. Note that this only makes sense when
|
||||
* used with a device ID.
|
||||
*/
|
||||
remember: z.boolean().optional().default(false),
|
||||
export const authenticateAccountDataSchema = z
|
||||
.object({
|
||||
locale: localeSchema,
|
||||
username: z.string(),
|
||||
password: oldPasswordSchema,
|
||||
emailOtp: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
emailOtp: z.string().optional(),
|
||||
})
|
||||
export type AuthenticateAccountData = z.TypeOf<
|
||||
typeof authenticateAccountDataSchema
|
||||
>
|
||||
|
||||
export type SignInCredentials = z.TypeOf<typeof signInCredentialsSchema>
|
||||
export const createAccountDataSchema = z
|
||||
.object({
|
||||
locale: localeSchema,
|
||||
handle: handleSchema,
|
||||
email: emailSchema,
|
||||
password: z.intersection(oldPasswordSchema, newPasswordSchema),
|
||||
inviteCode: tokenSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type CreateAccountData = z.TypeOf<typeof createAccountDataSchema>
|
||||
|
||||
export const resetPasswordRequestDataSchema = z
|
||||
.object({
|
||||
locale: localeSchema,
|
||||
email: emailSchema,
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ResetPasswordRequestData = z.TypeOf<
|
||||
typeof resetPasswordRequestDataSchema
|
||||
>
|
||||
|
||||
export const resetPasswordConfirmDataSchema = z
|
||||
.object({
|
||||
token: tokenSchema,
|
||||
password: z.intersection(oldPasswordSchema, newPasswordSchema),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ResetPasswordConfirmData = z.TypeOf<
|
||||
typeof resetPasswordConfirmDataSchema
|
||||
>
|
||||
|
||||
export type DeviceAccountInfo = {
|
||||
remembered: boolean
|
||||
@ -28,7 +89,14 @@ export type DeviceAccountInfo = {
|
||||
}
|
||||
|
||||
// Export all types needed to implement the AccountStore interface
|
||||
export type { Account, DeviceId, Sub }
|
||||
export {
|
||||
type Account,
|
||||
type DeviceId,
|
||||
HandleUnavailableError,
|
||||
InvalidRequestError,
|
||||
SecondAuthenticationFactorRequiredError,
|
||||
type Sub,
|
||||
}
|
||||
|
||||
export type AccountInfo = {
|
||||
account: Account
|
||||
@ -36,10 +104,17 @@ export type AccountInfo = {
|
||||
}
|
||||
|
||||
export interface AccountStore {
|
||||
authenticateAccount(
|
||||
credentials: SignInCredentials,
|
||||
deviceId: DeviceId,
|
||||
): Awaitable<AccountInfo | null>
|
||||
/**
|
||||
* @throws {HandleUnavailableError} - To indicate that the handle is already taken
|
||||
* @throws {InvalidRequestError} - To indicate that some data is invalid
|
||||
*/
|
||||
createAccount(data: CreateAccountData): Awaitable<Account>
|
||||
|
||||
/**
|
||||
* @throws {InvalidRequestError} - When the credentials are not valid
|
||||
* @throws {SecondAuthenticationFactorRequiredError} - To indicate that an {@link SecondAuthenticationFactorRequiredError.type} is required in the credentials
|
||||
*/
|
||||
authenticateAccount(data: AuthenticateAccountData): Awaitable<Account>
|
||||
|
||||
addAuthorizedClient(
|
||||
deviceId: DeviceId,
|
||||
@ -47,6 +122,19 @@ export interface AccountStore {
|
||||
clientId: ClientId,
|
||||
): Awaitable<void>
|
||||
|
||||
/**
|
||||
* @param remember If false, the account must not be returned from
|
||||
* {@link AccountStore.listDeviceAccounts}.
|
||||
*/
|
||||
addDeviceAccount(
|
||||
deviceId: DeviceId,
|
||||
sub: Sub,
|
||||
remember: boolean,
|
||||
): Awaitable<DeviceAccountInfo>
|
||||
|
||||
/**
|
||||
* @returns The account info, whether the account, even if remember was false.
|
||||
*/
|
||||
getDeviceAccount(deviceId: DeviceId, sub: Sub): Awaitable<AccountInfo | null>
|
||||
removeDeviceAccount(deviceId: DeviceId, sub: Sub): Awaitable<void>
|
||||
|
||||
@ -55,23 +143,30 @@ export interface AccountStore {
|
||||
* be returned. The others will be ignored.
|
||||
*/
|
||||
listDeviceAccounts(deviceId: DeviceId): Awaitable<AccountInfo[]>
|
||||
|
||||
resetPasswordRequest(data: ResetPasswordRequestData): Awaitable<void>
|
||||
resetPasswordConfirm(data: ResetPasswordConfirmData): Awaitable<void>
|
||||
|
||||
/**
|
||||
* @throws {HandleUnavailableError} - To indicate that the handle is already taken
|
||||
*/
|
||||
verifyHandleAvailability(handle: string): Awaitable<void>
|
||||
}
|
||||
|
||||
export function isAccountStore(
|
||||
implementation: Record<string, unknown> & Partial<AccountStore>,
|
||||
): implementation is Record<string, unknown> & AccountStore {
|
||||
return (
|
||||
typeof implementation.authenticateAccount === 'function' &&
|
||||
typeof implementation.getDeviceAccount === 'function' &&
|
||||
typeof implementation.addAuthorizedClient === 'function' &&
|
||||
typeof implementation.listDeviceAccounts === 'function' &&
|
||||
typeof implementation.removeDeviceAccount === 'function'
|
||||
)
|
||||
}
|
||||
export const isAccountStore = buildInterfaceChecker<AccountStore>([
|
||||
'createAccount',
|
||||
'authenticateAccount',
|
||||
'addAuthorizedClient',
|
||||
'addDeviceAccount',
|
||||
'getDeviceAccount',
|
||||
'removeDeviceAccount',
|
||||
'listDeviceAccounts',
|
||||
'resetPasswordRequest',
|
||||
'resetPasswordConfirm',
|
||||
'verifyHandleAvailability',
|
||||
])
|
||||
|
||||
export function asAccountStore(
|
||||
implementation?: Record<string, unknown> & Partial<AccountStore>,
|
||||
): AccountStore {
|
||||
export function asAccountStore<V>(implementation: V): V & AccountStore {
|
||||
if (!implementation || !isAccountStore(implementation)) {
|
||||
throw new Error('Invalid AccountStore implementation')
|
||||
}
|
||||
|
15
packages/oauth/oauth-provider/src/account/sign-in-data.ts
Normal file
15
packages/oauth/oauth-provider/src/account/sign-in-data.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
import { authenticateAccountDataSchema } from './account-store.js'
|
||||
|
||||
export const signInDataSchema = authenticateAccountDataSchema
|
||||
.extend({
|
||||
/**
|
||||
* If false, the account must not be returned from
|
||||
* {@link AccountStore.listDeviceAccounts}. Note that this only makes sense when
|
||||
* used with a device ID.
|
||||
*/
|
||||
remember: z.boolean().optional().default(false),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SignInData = z.TypeOf<typeof signInDataSchema>
|
11
packages/oauth/oauth-provider/src/account/sign-up-data.ts
Normal file
11
packages/oauth/oauth-provider/src/account/sign-up-data.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import { hcaptchaTokenSchema } from '../lib/hcaptcha.js'
|
||||
import { createAccountDataSchema } from './account-store.js'
|
||||
|
||||
export const signUpDataSchema = createAccountDataSchema
|
||||
.extend({
|
||||
hcaptchaToken: hcaptchaTokenSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SignUpData = z.TypeOf<typeof signUpDataSchema>
|
@ -1,28 +1,43 @@
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import type {
|
||||
AuthorizeData,
|
||||
AvailableLocales,
|
||||
CustomizationData,
|
||||
ErrorData,
|
||||
} from './backend-data'
|
||||
import { AuthorizeView } from './views/authorize-view'
|
||||
import { ErrorView } from './views/error-view'
|
||||
} from './backend-types.ts'
|
||||
import { LocaleProvider } from './locales/locale-provider.tsx'
|
||||
import { AuthorizeView } from './views/authorize/authorize-view.tsx'
|
||||
import { ErrorView } from './views/error/error-view.tsx'
|
||||
|
||||
export type AppProps = {
|
||||
availableLocales?: AvailableLocales
|
||||
authorizeData?: AuthorizeData
|
||||
customizationData?: CustomizationData
|
||||
errorData?: ErrorData
|
||||
}
|
||||
|
||||
export function App({ authorizeData, customizationData, errorData }: AppProps) {
|
||||
if (authorizeData && !errorData) {
|
||||
return (
|
||||
<AuthorizeView
|
||||
customizationData={customizationData}
|
||||
authorizeData={authorizeData}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<ErrorView customizationData={customizationData} errorData={errorData} />
|
||||
)
|
||||
}
|
||||
export function App({
|
||||
availableLocales,
|
||||
authorizeData,
|
||||
customizationData,
|
||||
errorData,
|
||||
}: AppProps) {
|
||||
return (
|
||||
<LocaleProvider availableLocales={availableLocales}>
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error }) => (
|
||||
<ErrorView error={error} customizationData={customizationData} />
|
||||
)}
|
||||
>
|
||||
{errorData || !authorizeData ? (
|
||||
<ErrorView error={errorData} customizationData={customizationData} />
|
||||
) : (
|
||||
<AuthorizeView
|
||||
customizationData={customizationData}
|
||||
authorizeData={authorizeData}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</LocaleProvider>
|
||||
)
|
||||
}
|
||||
|
@ -1,72 +1,27 @@
|
||||
import { OAuthClientMetadata } from '@atproto/oauth-types'
|
||||
import {
|
||||
AuthorizeData,
|
||||
AvailableLocales,
|
||||
CustomizationData,
|
||||
ErrorData,
|
||||
} from './backend-types.ts'
|
||||
|
||||
// TODO: Find a way to share these types with the backend code
|
||||
|
||||
export type Account = {
|
||||
sub: string
|
||||
aud: string
|
||||
|
||||
email?: string
|
||||
name?: string
|
||||
preferred_username?: string
|
||||
picture?: string
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
account: Account
|
||||
info?: never // Prevent relying on this in the frontend
|
||||
|
||||
selected: boolean
|
||||
loginRequired: boolean
|
||||
consentRequired: boolean
|
||||
}
|
||||
|
||||
export type LinkDefinition = {
|
||||
title: string
|
||||
href: string
|
||||
rel?: string
|
||||
}
|
||||
|
||||
export type CustomizationData = {
|
||||
name?: string
|
||||
logo?: string
|
||||
links?: LinkDefinition[]
|
||||
}
|
||||
|
||||
export type ErrorData = {
|
||||
error: string
|
||||
error_description: string
|
||||
}
|
||||
|
||||
export type ScopeDetail = {
|
||||
scope: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type AuthorizeData = {
|
||||
clientId: string
|
||||
clientMetadata: OAuthClientMetadata
|
||||
clientTrusted: boolean
|
||||
requestUri: string
|
||||
csrfCookie: string
|
||||
loginHint?: string
|
||||
scopeDetails?: ScopeDetail[]
|
||||
newSessionsRequireConsent: boolean
|
||||
sessions: Session[]
|
||||
}
|
||||
|
||||
// see "declareBackendData()" in the backend
|
||||
const readBackendData = <T>(key: string): T | undefined => {
|
||||
function readBackendData<T>(key: string): T | undefined {
|
||||
const value = window[key] as T | undefined
|
||||
delete window[key] // Prevent accidental usage / potential leaks to dependencies
|
||||
return value
|
||||
}
|
||||
|
||||
// These values are injected by the backend when it builds the
|
||||
// page HTML.
|
||||
// page HTML. See "declareBackendData()" in the backend.
|
||||
|
||||
/** @deprecated Do not import directly. Only import this from main.tsx */
|
||||
export const availableLocales =
|
||||
readBackendData<AvailableLocales>('__availableLocales')
|
||||
/** @deprecated Do not import directly. Only import this from main.tsx */
|
||||
export const customizationData = readBackendData<CustomizationData>(
|
||||
'__customizationData',
|
||||
)
|
||||
/** @deprecated Do not import directly. Only import this from main.tsx */
|
||||
export const errorData = readBackendData<ErrorData>('__errorData')
|
||||
/** @deprecated Do not import directly. Only import this from main.tsx */
|
||||
export const authorizeData = readBackendData<AuthorizeData>('__authorizeData')
|
||||
|
@ -0,0 +1,66 @@
|
||||
import type { OAuthClientMetadata } from '@atproto/oauth-types'
|
||||
|
||||
// @TODO: Find a way to share these types with the backend code
|
||||
|
||||
export type Account = {
|
||||
sub: string
|
||||
aud: string | [string, ...string[]]
|
||||
|
||||
email?: string
|
||||
email_verified?: boolean
|
||||
name?: string
|
||||
preferred_username?: string
|
||||
picture?: string
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
account: Account
|
||||
info?: never // Prevent relying on this in the frontend
|
||||
|
||||
selected: boolean
|
||||
loginRequired: boolean
|
||||
consentRequired: boolean
|
||||
}
|
||||
|
||||
export type LocalizedString = string | ({ en: string } & Record<string, string>)
|
||||
|
||||
export type AvailableLocales = readonly string[]
|
||||
|
||||
export type LinkDefinition = {
|
||||
title: LocalizedString
|
||||
href: string
|
||||
rel?: string
|
||||
}
|
||||
|
||||
export type CustomizationData = {
|
||||
// Functional customization
|
||||
hcaptchaSiteKey?: string
|
||||
inviteCodeRequired?: boolean
|
||||
availableUserDomains?: string[]
|
||||
|
||||
// Aesthetic customization
|
||||
name?: string
|
||||
logo?: string
|
||||
links?: LinkDefinition[]
|
||||
}
|
||||
|
||||
export type ErrorData = {
|
||||
error: string
|
||||
error_description: string
|
||||
}
|
||||
|
||||
export type ScopeDetail = {
|
||||
scope: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type AuthorizeData = {
|
||||
clientId: string
|
||||
clientMetadata: OAuthClientMetadata
|
||||
clientTrusted: boolean
|
||||
requestUri: string
|
||||
loginHint?: string
|
||||
scopeDetails?: ScopeDetail[]
|
||||
newSessionsRequireConsent: boolean
|
||||
sessions: Session[]
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
import { OAuthClientMetadata } from '@atproto/oauth-types'
|
||||
import { FormEvent } from 'react'
|
||||
|
||||
import { Account, ScopeDetail } from '../backend-data'
|
||||
import { Override } from '../lib/util'
|
||||
import { AccountIdentifier } from './account-identifier'
|
||||
import { Button } from './button'
|
||||
import { ClientName } from './client-name'
|
||||
import { FormCard, FormCardProps } from './form-card'
|
||||
|
||||
export type AcceptFormProps = Override<
|
||||
FormCardProps,
|
||||
{
|
||||
clientId: string
|
||||
clientMetadata: OAuthClientMetadata
|
||||
clientTrusted: boolean
|
||||
|
||||
account: Account
|
||||
scopeDetails?: ScopeDetail[]
|
||||
|
||||
onAccept: () => void
|
||||
acceptLabel?: string
|
||||
|
||||
onReject: () => void
|
||||
rejectLabel?: string
|
||||
|
||||
onBack?: () => void
|
||||
backLabel?: string
|
||||
}
|
||||
>
|
||||
|
||||
export function AcceptForm({
|
||||
clientId,
|
||||
clientMetadata,
|
||||
clientTrusted,
|
||||
|
||||
account,
|
||||
scopeDetails,
|
||||
|
||||
onAccept,
|
||||
acceptLabel = 'Accept',
|
||||
onReject,
|
||||
rejectLabel = 'Deny access',
|
||||
onBack,
|
||||
backLabel = 'Back',
|
||||
|
||||
...props
|
||||
}: AcceptFormProps) {
|
||||
const doSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
onAccept()
|
||||
}
|
||||
|
||||
return (
|
||||
<FormCard
|
||||
onSubmit={doSubmit}
|
||||
cancel={onBack && <Button onClick={onBack}>{backLabel}</Button>}
|
||||
actions={
|
||||
<>
|
||||
<Button type="submit" color="brand">
|
||||
{acceptLabel}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onReject}>{rejectLabel}</Button>
|
||||
</>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{clientTrusted && clientMetadata.logo_uri && (
|
||||
<div key="logo" className="flex items-center justify-center">
|
||||
<img
|
||||
crossOrigin="anonymous"
|
||||
src={clientMetadata.logo_uri}
|
||||
alt={clientMetadata.client_name}
|
||||
className="w-16 h-16 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
<ClientName
|
||||
clientId={clientId}
|
||||
clientMetadata={clientMetadata}
|
||||
clientTrusted={clientTrusted}
|
||||
/>{' '}
|
||||
is asking for permission to access your account (
|
||||
<AccountIdentifier account={account} />
|
||||
).
|
||||
</p>
|
||||
|
||||
<p>
|
||||
By clicking <b>{acceptLabel}</b>, you allow this application to perform
|
||||
the following actions in accordance to their{' '}
|
||||
<a
|
||||
href={clientMetadata.tos_uri}
|
||||
rel="nofollow noopener"
|
||||
target="_blank"
|
||||
className="text-brand underline"
|
||||
>
|
||||
terms of service
|
||||
</a>
|
||||
{' and '}
|
||||
<a
|
||||
href={clientMetadata.policy_uri}
|
||||
rel="nofollow noopener"
|
||||
target="_blank"
|
||||
className="text-brand underline"
|
||||
>
|
||||
privacy policy
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
|
||||
{scopeDetails?.length ? (
|
||||
<ul className="list-disc list-inside">
|
||||
{scopeDetails.map(
|
||||
({ scope, description = getScopeDescription(scope) }) => (
|
||||
<li key={scope}>{description}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
</FormCard>
|
||||
)
|
||||
}
|
||||
|
||||
function getScopeDescription(scope: string): string {
|
||||
switch (scope) {
|
||||
case 'atproto':
|
||||
return 'Uniquely identify you'
|
||||
case 'transition:generic':
|
||||
return 'Access your account data (except chat messages)'
|
||||
case 'transition:chat.bsky':
|
||||
return 'Access your chat messages'
|
||||
default:
|
||||
return scope
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import { HTMLAttributes } from 'react'
|
||||
|
||||
import { Account } from '../backend-data'
|
||||
|
||||
export type AccountIdentifierProps = {
|
||||
account: Account
|
||||
}
|
||||
|
||||
export function AccountIdentifier({
|
||||
account,
|
||||
...attrs
|
||||
}: AccountIdentifierProps & HTMLAttributes<Element>) {
|
||||
return (
|
||||
<b {...attrs}>
|
||||
{account.preferred_username || account.email || account.sub}
|
||||
</b>
|
||||
)
|
||||
}
|
@ -1,127 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Account } from '../backend-data'
|
||||
import { Override } from '../lib/util'
|
||||
import { Button } from './button'
|
||||
import { FormCard, FormCardProps } from './form-card'
|
||||
import { AtSymbolIcon } from './icons/at-symbol-icon'
|
||||
import { CaretRightIcon } from './icons/caret-right-icon'
|
||||
import { InputContainer } from './input-container'
|
||||
import { Fieldset } from './fieldset'
|
||||
|
||||
export type AccountPickerProps = Override<
|
||||
FormCardProps,
|
||||
{
|
||||
accounts: readonly Account[]
|
||||
|
||||
onAccount: (account: Account) => void
|
||||
accountAria?: (account: Account) => string
|
||||
|
||||
onOther?: () => void
|
||||
otherLabel?: ReactNode
|
||||
otherAria?: string
|
||||
|
||||
onBack?: () => void
|
||||
backLabel?: ReactNode
|
||||
backAria?: string
|
||||
}
|
||||
>
|
||||
|
||||
export function AccountPicker({
|
||||
accounts,
|
||||
|
||||
onAccount,
|
||||
accountAria = (a) => `Sign in as ${a.name}`,
|
||||
|
||||
onOther = undefined,
|
||||
otherLabel = 'Another account',
|
||||
otherAria = 'Login to account that is not listed',
|
||||
|
||||
onBack,
|
||||
backAria,
|
||||
backLabel = backAria,
|
||||
|
||||
...props
|
||||
}: AccountPickerProps) {
|
||||
return (
|
||||
<FormCard
|
||||
{...props}
|
||||
cancel={
|
||||
onBack && (
|
||||
<Button onClick={onBack} aria-label={backAria}>
|
||||
{backLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Fieldset title="Sign in as...">
|
||||
{accounts.map((account) => {
|
||||
const [name, identifier] = [
|
||||
account.name,
|
||||
account.preferred_username,
|
||||
account.email,
|
||||
account.sub,
|
||||
].filter(Boolean) as [string, string?]
|
||||
|
||||
return (
|
||||
<InputContainer
|
||||
key={account.sub}
|
||||
onClick={() => onAccount(account)}
|
||||
role="button"
|
||||
aria-label={accountAria(account)}
|
||||
icon={
|
||||
account.picture ? (
|
||||
<img
|
||||
crossOrigin="anonymous"
|
||||
src={account.picture}
|
||||
alt={name}
|
||||
className="-ml-1 w-6 h-6 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
className="-ml-1 w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
>
|
||||
<circle cx="12" cy="12" r="12" fill="#0070ff"></circle>
|
||||
<circle cx="12" cy="9.5" r="3.5" fill="#fff"></circle>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#fff"
|
||||
d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
append={<CaretRightIcon className="h-4" />}
|
||||
>
|
||||
<span className="flex flex-wrap items-center">
|
||||
<span className="font-medium truncate mr-2">{name}</span>
|
||||
{identifier && (
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400 truncate">
|
||||
{identifier}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</InputContainer>
|
||||
)
|
||||
})}
|
||||
|
||||
{onOther && (
|
||||
<InputContainer
|
||||
onClick={onOther}
|
||||
aria-label={otherAria}
|
||||
role="button"
|
||||
append={<CaretRightIcon className="h-4" />}
|
||||
icon={<AtSymbolIcon className="h-4" />}
|
||||
>
|
||||
<span className="truncate text-gray-700 dark:text-gray-400">
|
||||
{otherLabel}
|
||||
</span>
|
||||
</InputContainer>
|
||||
)}
|
||||
</Fieldset>
|
||||
</FormCard>
|
||||
)
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
import { ButtonHTMLAttributes } from 'react'
|
||||
import { clsx } from '../lib/clsx'
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className,
|
||||
type = 'button',
|
||||
role = 'Button',
|
||||
color = 'grey',
|
||||
disabled = false,
|
||||
loading = undefined,
|
||||
...props
|
||||
}: {
|
||||
color?: 'brand' | 'grey'
|
||||
loading?: boolean
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
role={role}
|
||||
type={type}
|
||||
disabled={disabled || loading === true}
|
||||
{...props}
|
||||
className={clsx(
|
||||
'py-2 px-6 rounded-lg truncate cursor-pointer touch-manipulation tracking-wide overflow-hidden',
|
||||
color === 'brand'
|
||||
? 'bg-brand text-white'
|
||||
: 'bg-slate-100 hover:bg-slate-200 text-slate-600 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
import {
|
||||
isOAuthClientIdDiscoverable,
|
||||
isOAuthClientIdLoopback,
|
||||
OAuthClientMetadata,
|
||||
} from '@atproto/oauth-types'
|
||||
import { HTMLAttributes } from 'react'
|
||||
|
||||
import { UrlViewer } from './url-viewer'
|
||||
|
||||
export type ClientNameProps = {
|
||||
clientId: string
|
||||
clientMetadata: OAuthClientMetadata
|
||||
clientTrusted: boolean
|
||||
loopbackClientName?: string
|
||||
} & HTMLAttributes<Element>
|
||||
|
||||
export function ClientName({
|
||||
clientId,
|
||||
clientMetadata,
|
||||
clientTrusted,
|
||||
loopbackClientName = 'An application on your device',
|
||||
...attrs
|
||||
}: ClientNameProps) {
|
||||
if (clientTrusted && clientMetadata.client_name) {
|
||||
return <span {...attrs}>{clientMetadata.client_name}</span>
|
||||
}
|
||||
|
||||
if (isOAuthClientIdLoopback(clientId)) {
|
||||
return <span {...attrs}>{loopbackClientName}</span>
|
||||
}
|
||||
|
||||
if (isOAuthClientIdDiscoverable(clientId)) {
|
||||
return <UrlViewer {...attrs} url={clientId} path />
|
||||
}
|
||||
|
||||
return <span {...attrs}>{clientId}</span>
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
import { FieldsetHTMLAttributes, forwardRef, ReactNode } from 'react'
|
||||
import { Override } from '../lib/util'
|
||||
|
||||
export type FieldsetCardProps = Override<
|
||||
FieldsetHTMLAttributes<HTMLFieldSetElement>,
|
||||
{
|
||||
title?: ReactNode
|
||||
}
|
||||
>
|
||||
|
||||
export const Fieldset = forwardRef<HTMLFieldSetElement, FieldsetCardProps>(
|
||||
({ title, children, ...props }, ref) => (
|
||||
<fieldset ref={ref} {...props}>
|
||||
{title && (
|
||||
<p
|
||||
key="title"
|
||||
className="mb-1 text-slate-600 dark:text-slate-400 text-sm font-medium"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col space-y-4">{children}</div>
|
||||
</fieldset>
|
||||
),
|
||||
)
|
@ -1,47 +0,0 @@
|
||||
import { FormHTMLAttributes, forwardRef, ReactNode } from 'react'
|
||||
import { InfoCard } from './info-card'
|
||||
import { clsx } from '../lib/clsx'
|
||||
import { Override } from '../lib/util'
|
||||
|
||||
export type FormCardProps = Override<
|
||||
FormHTMLAttributes<HTMLFormElement>,
|
||||
{
|
||||
append?: ReactNode
|
||||
error?: ReactNode
|
||||
cancel?: ReactNode
|
||||
actions?: ReactNode
|
||||
}
|
||||
>
|
||||
|
||||
export const FormCard = forwardRef<HTMLFormElement, FormCardProps>(
|
||||
({ actions, cancel, append, className, children, error, ...props }, ref) => {
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
className={clsx('flex flex-col py-4 space-y-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="space-y-4">{children}</div>
|
||||
|
||||
{append && <div key="append">{append}</div>}
|
||||
|
||||
{error && (
|
||||
<InfoCard key="error" role="alert">
|
||||
{error}
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{(actions || cancel) && (
|
||||
<div
|
||||
key="buttons"
|
||||
className="flex flex-wrap flex-row-reverse items-center justify-end space-x-reverse space-x-2"
|
||||
>
|
||||
{actions}
|
||||
<div className="flex-auto" />
|
||||
{cancel}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
},
|
||||
)
|
@ -0,0 +1,43 @@
|
||||
import { useLingui } from '@lingui/react/macro'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { EyeIcon, EyeSlashIcon } from '../utils/icons.tsx'
|
||||
import { Button, ButtonProps } from './button.tsx'
|
||||
|
||||
export type ButtonToggleVisibilityProps = Override<
|
||||
Omit<ButtonProps, 'aria-label' | 'square'>,
|
||||
{
|
||||
visible: boolean
|
||||
toggleVisible: () => void
|
||||
}
|
||||
>
|
||||
|
||||
/**
|
||||
* Generic button to toggle visibility of an item (e.g. password).
|
||||
*/
|
||||
export function ButtonToggleVisibility({
|
||||
visible,
|
||||
toggleVisible,
|
||||
|
||||
// button
|
||||
onClick,
|
||||
...props
|
||||
}: ButtonToggleVisibilityProps) {
|
||||
const { t } = useLingui()
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
square
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
if (!event.defaultPrevented) toggleVisible()
|
||||
}}
|
||||
aria-label={visible ? t`Hide` : t`Make visible`}
|
||||
>
|
||||
{visible ? (
|
||||
<EyeIcon className="w-5" aria-hidden />
|
||||
) : (
|
||||
<EyeSlashIcon className="w-5" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
import { JSX } from 'react'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type ButtonProps = Override<
|
||||
JSX.IntrinsicElements['button'],
|
||||
{
|
||||
color?: 'brand' | 'grey'
|
||||
loading?: boolean
|
||||
transparent?: boolean
|
||||
square?: boolean
|
||||
}
|
||||
>
|
||||
|
||||
export function Button({
|
||||
color = 'grey',
|
||||
transparent = false,
|
||||
loading = undefined,
|
||||
square = false,
|
||||
|
||||
// button
|
||||
children,
|
||||
className,
|
||||
type = 'button',
|
||||
role = 'Button',
|
||||
disabled = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
role={role}
|
||||
type={type}
|
||||
disabled={disabled || loading === true}
|
||||
{...props}
|
||||
className={clsx(
|
||||
'rounded-lg truncate cursor-pointer touch-manipulation tracking-wide overflow-hidden',
|
||||
square ? 'p-2' : 'py-2 px-6',
|
||||
color === 'brand'
|
||||
? clsx(
|
||||
'accent-slate-100',
|
||||
transparent
|
||||
? 'bg-transparent text-brand'
|
||||
: 'bg-brand text-brand-c',
|
||||
)
|
||||
: color === 'grey'
|
||||
? clsx(
|
||||
'accent-brand',
|
||||
'text-slate-600 dark:text-slate-300',
|
||||
'hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
transparent ? 'bg-transparent' : 'bg-gray-100 dark:bg-gray-800',
|
||||
)
|
||||
: undefined,
|
||||
'disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
import { JSX, ReactNode, createContext, useMemo } from 'react'
|
||||
import { useRandomString } from '../../hooks/use-random-string.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type FieldsetContextValue = {
|
||||
disabled: boolean
|
||||
labelId?: string
|
||||
}
|
||||
|
||||
export const FieldsetContext = createContext<FieldsetContextValue>({
|
||||
disabled: false,
|
||||
})
|
||||
FieldsetContext.displayName = 'FieldsetContext'
|
||||
|
||||
export type FieldsetCardProps = Override<
|
||||
Omit<JSX.IntrinsicElements['fieldset'], 'aria-labelledby'>,
|
||||
{
|
||||
label?: ReactNode
|
||||
}
|
||||
>
|
||||
|
||||
export function Fieldset({
|
||||
label,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: FieldsetCardProps) {
|
||||
const labelId = useRandomString({ prefix: 'fieldset-' })
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
disabled: disabled ?? false,
|
||||
labelId: label ? labelId : undefined,
|
||||
}),
|
||||
[disabled, label, labelId],
|
||||
)
|
||||
|
||||
return (
|
||||
<fieldset {...props} aria-labelledby={labelId} disabled={disabled}>
|
||||
{label && (
|
||||
<legend
|
||||
id={labelId}
|
||||
key="title"
|
||||
className="mb-1 text-slate-600 dark:text-slate-400 text-sm font-medium"
|
||||
>
|
||||
{label}
|
||||
</legend>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
<FieldsetContext value={contextValue}>{children}</FieldsetContext>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
import { Trans } from '@lingui/react/macro'
|
||||
import { FormEvent, ReactNode, useCallback } from 'react'
|
||||
import {
|
||||
UseAsyncActionOptions,
|
||||
useAsyncAction,
|
||||
} from '../../hooks/use-async-action.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { ErrorCard } from '../utils/error-card.tsx'
|
||||
import { Button } from './button.tsx'
|
||||
import { FormCard, FormCardProps } from './form-card.tsx'
|
||||
|
||||
export type { AsyncActionController } from '../../hooks/use-async-action.ts'
|
||||
|
||||
export type ErrorRender = (data: { error: Error }) => ReactNode
|
||||
export const errorRenderDefault: ErrorRender = ({ error }) => (
|
||||
<ErrorCard error={error} />
|
||||
)
|
||||
|
||||
export type FormCardAsyncProps = Override<
|
||||
Override<
|
||||
Omit<FormCardProps, 'cancel' | 'actions' | 'prepend'>,
|
||||
Pick<UseAsyncActionOptions, 'ref' | 'onLoading' | 'onError'>
|
||||
>,
|
||||
{
|
||||
invalid?: boolean
|
||||
disabled?: boolean
|
||||
|
||||
onSubmit: (signal: AbortSignal) => void | PromiseLike<void>
|
||||
submitLabel?: ReactNode
|
||||
|
||||
onCancel?: () => void
|
||||
cancelLabel?: ReactNode
|
||||
|
||||
errorRender?: ErrorRender
|
||||
}
|
||||
>
|
||||
|
||||
export function FormCardAsync({
|
||||
invalid,
|
||||
disabled,
|
||||
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
|
||||
onCancel = undefined,
|
||||
cancelLabel,
|
||||
|
||||
errorRender = errorRenderDefault,
|
||||
|
||||
// UseAsyncActionOptions
|
||||
ref,
|
||||
onLoading,
|
||||
onError,
|
||||
|
||||
// FormCardProps
|
||||
children,
|
||||
...props
|
||||
}: FormCardAsyncProps) {
|
||||
const { run, loading, error } = useAsyncAction(onSubmit, {
|
||||
ref,
|
||||
onError,
|
||||
onLoading,
|
||||
})
|
||||
|
||||
const doSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!event.currentTarget.reportValidity()) return
|
||||
|
||||
if (!disabled && !invalid) void run()
|
||||
},
|
||||
[disabled, invalid, run],
|
||||
)
|
||||
|
||||
return (
|
||||
<FormCard
|
||||
{...props}
|
||||
onSubmit={doSubmit}
|
||||
disabled={disabled || loading}
|
||||
prepend={error != null ? errorRender({ error }) : undefined}
|
||||
cancel={
|
||||
onCancel && (
|
||||
<Button onClick={onCancel}>
|
||||
{cancelLabel || <Trans>Cancel</Trans>}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
color="brand"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
>
|
||||
{submitLabel || <Trans>Submit</Trans>}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FormCard>
|
||||
)
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
import { JSX, ReactNode } from 'react'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type FormCardProps = Override<
|
||||
JSX.IntrinsicElements['form'],
|
||||
{
|
||||
disabled?: boolean
|
||||
append?: ReactNode
|
||||
prepend?: ReactNode
|
||||
cancel?: ReactNode
|
||||
actions?: ReactNode
|
||||
}
|
||||
>
|
||||
|
||||
export function FormCard({
|
||||
actions,
|
||||
cancel,
|
||||
append,
|
||||
children,
|
||||
prepend,
|
||||
disabled,
|
||||
|
||||
// form
|
||||
inert = disabled,
|
||||
...props
|
||||
}: FormCardProps) {
|
||||
return (
|
||||
<form {...props} inert={inert} className="flex flex-col space-y-4">
|
||||
{prepend && <div key="prepend">{prepend}</div>}
|
||||
|
||||
<div key="children" className="space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{append && <div key="append">{append}</div>}
|
||||
|
||||
{(actions || cancel) && (
|
||||
<div
|
||||
key="buttons"
|
||||
className="flex flex-wrap flex-row-reverse items-center justify-end space-x-reverse space-x-2"
|
||||
>
|
||||
{actions}
|
||||
<div className="flex-auto" />
|
||||
{cancel}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
import { JSX, ReactNode, useContext, useRef } from 'react'
|
||||
import { useRandomString } from '../../hooks/use-random-string.ts'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { mergeRefs } from '../../lib/ref.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { FieldsetContext } from './fieldset.tsx'
|
||||
import { InputContainer } from './input-container.tsx'
|
||||
|
||||
export type InputCheckboxProps = Override<
|
||||
Omit<JSX.IntrinsicElements['input'], 'className' | 'type' | 'children'>,
|
||||
{
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
>
|
||||
|
||||
export function InputCheckbox({
|
||||
className,
|
||||
children,
|
||||
|
||||
// input
|
||||
id,
|
||||
ref,
|
||||
disabled,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
...props
|
||||
}: InputCheckboxProps) {
|
||||
const htmlFor = useRandomString('input-checkbox-')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const ctx = useContext(FieldsetContext)
|
||||
|
||||
const inputId = id ?? htmlFor
|
||||
|
||||
return (
|
||||
<InputContainer
|
||||
ref={containerRef}
|
||||
className={clsx('cursor-pointer', className)}
|
||||
icon={
|
||||
<input
|
||||
{...props}
|
||||
disabled={disabled ?? ctx.disabled}
|
||||
aria-labelledby={
|
||||
children
|
||||
? // Prefer the local "<label>" element (through "htmlFor") over the wrapping "<fieldset>" to describe the checkbox.
|
||||
undefined
|
||||
: ariaLabelledBy ?? ctx.labelId
|
||||
}
|
||||
ref={mergeRefs([ref, inputRef])}
|
||||
id={inputId}
|
||||
className="accent-brand outline-none"
|
||||
type="checkbox"
|
||||
/>
|
||||
}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => {
|
||||
if (event.target === containerRef.current && !event.defaultPrevented) {
|
||||
inputRef.current?.click()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block w-full leading-[1.6] select-none cursor-pointer"
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
)}
|
||||
</InputContainer>
|
||||
)
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
import { JSX, ReactNode, useState } from 'react'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type InputContainerProps = Override<
|
||||
JSX.IntrinsicElements['div'],
|
||||
{
|
||||
icon: ReactNode
|
||||
append?: ReactNode
|
||||
bellow?: ReactNode
|
||||
}
|
||||
>
|
||||
|
||||
export function InputContainer({
|
||||
icon,
|
||||
append,
|
||||
bellow,
|
||||
|
||||
// div
|
||||
className,
|
||||
children,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
}: InputContainerProps) {
|
||||
const [hasFocus, setHasFocus] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event)
|
||||
if (!event.defaultPrevented) setHasFocus(true)
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event)
|
||||
if (!event.defaultPrevented) setHasFocus(false)
|
||||
}}
|
||||
className={clsx(
|
||||
// Layout
|
||||
'min-h-12',
|
||||
'max-w-full',
|
||||
'overflow-hidden',
|
||||
// Border
|
||||
'rounded-lg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
// Layout
|
||||
'px-1',
|
||||
'w-full min-h-12',
|
||||
'flex items-center justify-stretch',
|
||||
// Border
|
||||
'rounded-lg',
|
||||
bellow ? 'rounded-br-none rounded-bl-none' : undefined,
|
||||
'outline-none',
|
||||
'border-solid border-2 border-transparent',
|
||||
'focus:border-brand has-[:focus]:border-brand',
|
||||
'hover:border-gray-400 hover:focus:border-gray-400',
|
||||
'dark:hover:border-gray-500 dark:hover:focus:border-gray-500',
|
||||
// Background
|
||||
'bg-gray-100 focus:bg-slate-200 has-[:focus]:bg-slate-200',
|
||||
'dark:bg-slate-800 dark:focus:bg-slate-700 dark:has-[:focus]:bg-slate-700',
|
||||
// Font
|
||||
'text-slate-600 dark:text-slate-300',
|
||||
'accent-brand',
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<div
|
||||
className={clsx(
|
||||
'shrink-0 grow-0',
|
||||
'mx-1',
|
||||
hasFocus ? 'text-brand' : 'text-slate-500',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
<div className="ml-1 grow-0 shrink-0 flex items-center">{append}</div>
|
||||
</div>
|
||||
{bellow && (
|
||||
<div
|
||||
className={clsx(
|
||||
// Layout
|
||||
'px-3 py-2 space-x-2',
|
||||
'flex flex-row items-center gap-1',
|
||||
// Border
|
||||
'rounded-br-2 rounded-bl-2',
|
||||
// Background
|
||||
'bg-gray-200 dark:bg-slate-700',
|
||||
// Font
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
'text-sm italic',
|
||||
)}
|
||||
>
|
||||
{bellow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
import { useLingui } from '@lingui/react/macro'
|
||||
import { ChangeEvent, useCallback, useState } from 'react'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { AtSymbolIcon } from '../utils/icons.tsx'
|
||||
import { InputText, InputTextProps } from './input-text.tsx'
|
||||
|
||||
export type InputEmailAddressProps = Override<
|
||||
Omit<InputTextProps, 'type'>,
|
||||
{
|
||||
onEmail?: (email: string | undefined) => void
|
||||
}
|
||||
>
|
||||
|
||||
export function InputEmailAddress({
|
||||
onEmail,
|
||||
|
||||
// InputTextProps
|
||||
autoCapitalize = 'none',
|
||||
autoComplete = 'email',
|
||||
autoCorrect = 'off',
|
||||
dir = 'auto',
|
||||
icon = <AtSymbolIcon className="w-5" />,
|
||||
onBlur,
|
||||
onChange,
|
||||
pattern = '^[^@]+@[^@]+\\.[^@]+$',
|
||||
spellCheck = 'false',
|
||||
value,
|
||||
defaultValue = value,
|
||||
...props
|
||||
}: InputEmailAddressProps) {
|
||||
const { t } = useLingui()
|
||||
const [email, setEmail] = useState<string>(
|
||||
typeof defaultValue === 'string' ? defaultValue : '',
|
||||
)
|
||||
|
||||
const doChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const email = event.target.value.toLowerCase()
|
||||
|
||||
setEmail(email)
|
||||
onChange?.(event)
|
||||
onEmail?.(event.target.validity.valid ? email : undefined)
|
||||
},
|
||||
[onChange, onEmail],
|
||||
)
|
||||
|
||||
return (
|
||||
<InputText
|
||||
aria-label={t`Email`}
|
||||
placeholder={t`Email`}
|
||||
title={t`Email`}
|
||||
{...props}
|
||||
type="email"
|
||||
autoCapitalize={autoCapitalize}
|
||||
autoCorrect={autoCorrect}
|
||||
dir={dir}
|
||||
spellCheck={spellCheck}
|
||||
icon={icon}
|
||||
pattern={pattern}
|
||||
autoComplete={autoComplete}
|
||||
value={email}
|
||||
onChange={doChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
import { useLingui } from '@lingui/react/macro'
|
||||
import { ChangeEvent, useCallback, useState } from 'react'
|
||||
import { MIN_PASSWORD_LENGTH } from '../../lib/password.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { PasswordStrengthLabel } from '../utils/password-strength-label.tsx'
|
||||
import { PasswordStrengthMeter } from '../utils/password-strength-meter.tsx'
|
||||
import { InputPassword, InputPasswordProps } from './input-password.tsx'
|
||||
|
||||
export type InputNewPasswordProps = Override<
|
||||
Omit<InputPasswordProps, 'value' | 'defaultValue'>,
|
||||
{
|
||||
password?: string
|
||||
onPassword?: (password: undefined | string) => void
|
||||
}
|
||||
>
|
||||
|
||||
export function InputNewPassword({
|
||||
password: passwordInit = '',
|
||||
onPassword,
|
||||
|
||||
// InputPasswordProps
|
||||
onChange,
|
||||
autoComplete = 'new-password',
|
||||
minLength = MIN_PASSWORD_LENGTH,
|
||||
...props
|
||||
}: InputNewPasswordProps) {
|
||||
const { t } = useLingui()
|
||||
const [password, setPassword] = useState<string>(passwordInit)
|
||||
|
||||
const doChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target
|
||||
onChange?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
setPassword(value)
|
||||
onPassword?.(event.target.validity.valid ? value : undefined)
|
||||
},
|
||||
[onChange, onPassword],
|
||||
)
|
||||
|
||||
return (
|
||||
<InputPassword
|
||||
{...props}
|
||||
placeholder={t`Enter a password`}
|
||||
aria-label={t`Enter your new password`}
|
||||
title={t`Password with at least ${MIN_PASSWORD_LENGTH} characters`}
|
||||
minLength={minLength}
|
||||
onChange={doChange}
|
||||
value={password}
|
||||
autoComplete={autoComplete}
|
||||
bellow={
|
||||
<>
|
||||
<PasswordStrengthMeter password={password} />
|
||||
<PasswordStrengthLabel
|
||||
className="grow-1 min-w-max text-xs text-gray-500 dark:text-gray-400"
|
||||
password={password}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
import { useLingui } from '@lingui/react/macro'
|
||||
import { ChangeEvent, useCallback, useRef, useState } from 'react'
|
||||
import { mergeRefs } from '../../lib/ref.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { LockIcon } from '../utils/icons.tsx'
|
||||
import { ButtonToggleVisibility } from './button-toggle-visibility.tsx'
|
||||
import { InputText, InputTextProps } from './input-text.tsx'
|
||||
|
||||
export type InputPasswordProps = Override<
|
||||
Omit<InputTextProps, 'type' | 'children'>,
|
||||
{
|
||||
autoHide?: boolean
|
||||
}
|
||||
>
|
||||
|
||||
export function InputPassword({
|
||||
autoHide = true,
|
||||
|
||||
// InputTextProps
|
||||
onBlur,
|
||||
onChange,
|
||||
append,
|
||||
autoComplete = 'current-password',
|
||||
icon = <LockIcon className="w-5" />,
|
||||
value,
|
||||
defaultValue = value,
|
||||
ref,
|
||||
dir = 'auto',
|
||||
autoCapitalize = 'none',
|
||||
autoCorrect = 'off',
|
||||
spellCheck = 'false',
|
||||
...props
|
||||
}: InputPasswordProps) {
|
||||
const { t } = useLingui()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const [password, setPassword] = useState<string>(
|
||||
typeof defaultValue === 'string' ? defaultValue : '',
|
||||
)
|
||||
|
||||
const doChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(event)
|
||||
setPassword(event.target.value)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<InputText
|
||||
placeholder={t`Password`}
|
||||
aria-label={t`Password`}
|
||||
title={t`Password`}
|
||||
{...props}
|
||||
ref={mergeRefs([ref, inputRef])}
|
||||
dir={dir}
|
||||
autoCapitalize={autoCapitalize}
|
||||
autoCorrect={autoCorrect}
|
||||
spellCheck={spellCheck}
|
||||
icon={icon}
|
||||
onBlur={
|
||||
autoHide
|
||||
? (event) => {
|
||||
onBlur?.(event)
|
||||
if (!event.defaultPrevented) setVisible(false)
|
||||
}
|
||||
: onBlur
|
||||
}
|
||||
value={password}
|
||||
onChange={doChange}
|
||||
type={visible ? 'text' : 'password'}
|
||||
autoComplete={autoComplete}
|
||||
append={
|
||||
<>
|
||||
<ButtonToggleVisibility
|
||||
className="m-1"
|
||||
visible={visible}
|
||||
toggleVisible={() => {
|
||||
setVisible((prev) => !prev)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
{append}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
import { JSX, ReactNode, useContext, useRef } from 'react'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { mergeRefs } from '../../lib/ref.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { FieldsetContext } from './fieldset.tsx'
|
||||
import { InputContainer } from './input-container.tsx'
|
||||
|
||||
export type InputTextProps = Override<
|
||||
Omit<JSX.IntrinsicElements['input'], 'children'>,
|
||||
{
|
||||
icon?: ReactNode
|
||||
append?: ReactNode
|
||||
bellow?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
>
|
||||
|
||||
export function InputText({
|
||||
icon,
|
||||
append,
|
||||
bellow,
|
||||
className,
|
||||
|
||||
// input
|
||||
onFocus,
|
||||
onBlur,
|
||||
ref,
|
||||
disabled,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
...props
|
||||
}: InputTextProps) {
|
||||
const ctx = useContext(FieldsetContext)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const focusedRef = useRef(false) // ref instead of state to avoid re-renders
|
||||
|
||||
return (
|
||||
<InputContainer
|
||||
icon={icon}
|
||||
append={append}
|
||||
bellow={bellow}
|
||||
className={clsx('cursor-text', className)}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => {
|
||||
if (inputRef.current !== event.target) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (focusedRef.current && event.target !== inputRef.current) {
|
||||
// Prevent "blur" event from firing when clicking outside the input
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
{...props}
|
||||
disabled={disabled ?? ctx.disabled}
|
||||
aria-labelledby={ariaLabelledBy ?? ctx.labelId}
|
||||
ref={mergeRefs([ref, inputRef])}
|
||||
className="w-full bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder-gray-500 text-ellipsis"
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event)
|
||||
if (!event.defaultPrevented) focusedRef.current = true
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event)
|
||||
if (!event.defaultPrevented) focusedRef.current = false
|
||||
}}
|
||||
/>
|
||||
</InputContainer>
|
||||
)
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
import { useLingui } from '@lingui/react/macro'
|
||||
import { ChangeEvent, useState } from 'react'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { TokenIcon } from '../utils/icons.tsx'
|
||||
import { InputText, InputTextProps } from './input-text.tsx'
|
||||
|
||||
export type InputTokenProps = Override<
|
||||
Omit<
|
||||
InputTextProps,
|
||||
| 'type'
|
||||
| 'pattern'
|
||||
| 'autoCapitalize'
|
||||
| 'autoCorrect'
|
||||
| 'autoComplete'
|
||||
| 'spellCheck'
|
||||
| 'minLength'
|
||||
| 'maxLength'
|
||||
| 'placeholder'
|
||||
| 'dir'
|
||||
>,
|
||||
{
|
||||
example?: string
|
||||
onToken?: (code: string | null) => void
|
||||
}
|
||||
>
|
||||
|
||||
export const OTP_CODE_EXAMPLE = 'XXXXX-XXXXX'
|
||||
|
||||
export function InputToken({
|
||||
example = OTP_CODE_EXAMPLE,
|
||||
onToken,
|
||||
|
||||
// InputTextProps
|
||||
icon = <TokenIcon className="w-5" />,
|
||||
title = example,
|
||||
onChange,
|
||||
value,
|
||||
defaultValue = value,
|
||||
...props
|
||||
}: InputTokenProps) {
|
||||
const { t } = useLingui()
|
||||
const [token, setToken] = useState<string>(
|
||||
typeof defaultValue === 'string' ? defaultValue : '',
|
||||
)
|
||||
|
||||
return (
|
||||
<InputText
|
||||
{...props}
|
||||
type="text"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
minLength={11}
|
||||
maxLength={11}
|
||||
dir="auto"
|
||||
icon={icon}
|
||||
pattern="^[A-Z2-7]{5}-[A-Z2-7]{5}$"
|
||||
placeholder={t`Looks like ${example}`}
|
||||
title={title}
|
||||
value={token}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value, selectionEnd, selectionStart } = event.currentTarget
|
||||
|
||||
const fixedValue = fix(value)
|
||||
|
||||
event.currentTarget.value = fixedValue
|
||||
|
||||
// Move the cursor back where it was relative to the original value
|
||||
const pos = selectionEnd ?? selectionStart
|
||||
if (pos != null) {
|
||||
const fixedSlicedValue = fix(value.slice(0, pos))
|
||||
event.currentTarget.selectionStart =
|
||||
event.currentTarget.selectionEnd = fixedSlicedValue.length
|
||||
}
|
||||
|
||||
setToken(fixedValue)
|
||||
onChange?.(event)
|
||||
|
||||
if (!event.isDefaultPrevented()) {
|
||||
onToken?.(fixedValue.length === 11 ? fixedValue : null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function fix(value: string) {
|
||||
const normalized = value.toUpperCase().replaceAll(/[^A-Z2-7]/g, '')
|
||||
|
||||
if (normalized.length <= 5) return normalized
|
||||
|
||||
return `${normalized.slice(0, 5)}-${normalized.slice(5, 10)}`
|
||||
}
|
@ -0,0 +1,116 @@
|
||||
import { Trans } from '@lingui/react/macro'
|
||||
import { JSX, ReactNode, useCallback } from 'react'
|
||||
import { DisabledStep, Step, useStepper } from '../../hooks/use-stepper.ts'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type DoneFn = (...a: any) => unknown
|
||||
|
||||
export type WizardRenderProps<TDone extends DoneFn> = {
|
||||
/**
|
||||
* Indicates wether the render function being invoked corresponds to the step
|
||||
* currently active. The steps titles could, for example, be rendered in a
|
||||
* list of links, where the current step is highlighted (based on `current`).
|
||||
*
|
||||
* Another use for this is to render the next/previous steps in order to
|
||||
* provide animated transitions between steps. In this case, `current` would
|
||||
* be used to disable any form interaction with the form transitioning in/out.
|
||||
*/
|
||||
current: boolean
|
||||
invalid: boolean
|
||||
|
||||
prev?: () => void
|
||||
prevLabel: ReactNode
|
||||
|
||||
// On the last step, the "next()" function will actually be the done function
|
||||
next: (() => void) | TDone
|
||||
nextLabel: ReactNode
|
||||
}
|
||||
|
||||
export type WizardRenderFn<TDone extends DoneFn> = (
|
||||
data: WizardRenderProps<TDone>,
|
||||
) => ReactNode
|
||||
|
||||
export type WizardStep<TDone extends DoneFn> = Step & {
|
||||
titleRender?: WizardRenderFn<TDone>
|
||||
contentRender: WizardRenderFn<TDone>
|
||||
}
|
||||
|
||||
export type WizardCardProps<TDone extends DoneFn> = Override<
|
||||
Omit<JSX.IntrinsicElements['div'], 'children'>,
|
||||
{
|
||||
prevLabel?: ReactNode
|
||||
nextLabel?: ReactNode
|
||||
|
||||
onBack?: () => void
|
||||
backLabel?: ReactNode
|
||||
|
||||
onDone: TDone
|
||||
doneLabel?: ReactNode
|
||||
|
||||
steps: readonly (WizardStep<TDone> | DisabledStep)[]
|
||||
}
|
||||
>
|
||||
|
||||
export function WizardCard<TDone extends DoneFn>({
|
||||
prevLabel,
|
||||
nextLabel,
|
||||
|
||||
onBack,
|
||||
backLabel,
|
||||
|
||||
onDone,
|
||||
doneLabel,
|
||||
|
||||
steps,
|
||||
className,
|
||||
|
||||
...props
|
||||
}: WizardCardProps<TDone>) {
|
||||
const {
|
||||
atFirst,
|
||||
atLast,
|
||||
count,
|
||||
current,
|
||||
currentPosition,
|
||||
completed,
|
||||
toNext,
|
||||
toPrev,
|
||||
toRequired,
|
||||
} = useStepper(steps)
|
||||
|
||||
// Memoized to avoid re-renders in child (rendered) components
|
||||
const onNext = useCallback(() => {
|
||||
// If already at last step, go to the first incomplete (required) step
|
||||
if (!toNext()) toRequired()
|
||||
}, [toNext, toRequired])
|
||||
|
||||
const data: WizardRenderProps<TDone> = {
|
||||
// The current UI only displays the current title & content.
|
||||
current: true,
|
||||
invalid: current ? current.invalid : false,
|
||||
|
||||
prevLabel: (atFirst && backLabel) || prevLabel || <Trans>Back</Trans>,
|
||||
prev: atFirst ? onBack : toPrev,
|
||||
|
||||
nextLabel: (atLast && doneLabel) || nextLabel || <Trans>Next</Trans>,
|
||||
next: atLast && completed ? onDone : onNext,
|
||||
}
|
||||
|
||||
const stepTitle = current?.titleRender?.(data)
|
||||
const stepContent = current?.contentRender?.(data)
|
||||
|
||||
return (
|
||||
<div className={clsx(className, 'flex flex-col')} {...props}>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
<Trans>
|
||||
Step {currentPosition} of {count}
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{stepTitle && <h2 className="font-medium text-xl mb-4">{stepTitle}</h2>}
|
||||
|
||||
{stepContent}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
import { HTMLAttributes } from 'react'
|
||||
import { LinkDefinition } from '../backend-data'
|
||||
import { clsx } from '../lib/clsx'
|
||||
|
||||
export type HelpCardProps = {
|
||||
links?: readonly LinkDefinition[]
|
||||
}
|
||||
|
||||
export function HelpCard({
|
||||
links,
|
||||
|
||||
className,
|
||||
...attrs
|
||||
}: HelpCardProps &
|
||||
Omit<
|
||||
HTMLAttributes<HTMLParagraphElement>,
|
||||
keyof HelpCardProps | 'children'
|
||||
>) {
|
||||
const helpLink = links?.find((l) => l.rel === 'help')
|
||||
|
||||
if (!helpLink) return null
|
||||
|
||||
return (
|
||||
<p
|
||||
className={clsx(
|
||||
'text-sm rounded-md bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-400 p-3',
|
||||
className,
|
||||
)}
|
||||
{...attrs}
|
||||
>
|
||||
Having trouble?{' '}
|
||||
<a
|
||||
href={helpLink.href}
|
||||
rel={helpLink.rel}
|
||||
target="_blank"
|
||||
className="text-brand"
|
||||
>
|
||||
Contact {helpLink.title}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import { makeSvgComponent } from './util'
|
||||
|
||||
export const AlertIcon = makeSvgComponent(
|
||||
'M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z',
|
||||
)
|
@ -1,5 +0,0 @@
|
||||
import { makeSvgComponent } from './util'
|
||||
|
||||
export const AtSymbolIcon = makeSvgComponent(
|
||||
'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z',
|
||||
)
|
@ -1,5 +0,0 @@
|
||||
import { makeSvgComponent } from './util'
|
||||
|
||||
export const CaretRightIcon = makeSvgComponent(
|
||||
'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
|
||||
)
|
@ -1,5 +0,0 @@
|
||||
import { makeSvgComponent } from './util'
|
||||
|
||||
export const LockIcon = makeSvgComponent(
|
||||
'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
||||
)
|
@ -1,5 +0,0 @@
|
||||
import { makeSvgComponent } from './util'
|
||||
|
||||
export const TokenIcon = makeSvgComponent(
|
||||
'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z',
|
||||
)
|
@ -1,17 +0,0 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export const makeSvgComponent = (path: string) =>
|
||||
function (
|
||||
props: Omit<SVGProps<SVGSVGElement>, 'viewBox' | 'children' | 'xmlns'>,
|
||||
) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d={path}
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import { clsx } from '../lib/clsx'
|
||||
import { Override } from '../lib/util'
|
||||
import { AlertIcon } from './icons/alert-icon'
|
||||
import { InputLayout, InputLayoutProps } from './input-layout'
|
||||
|
||||
export type InfoCardProps = Override<
|
||||
InputLayoutProps,
|
||||
{
|
||||
role: 'alert' | 'status'
|
||||
}
|
||||
>
|
||||
|
||||
export function InfoCard({
|
||||
children,
|
||||
className,
|
||||
role = 'alert',
|
||||
...props
|
||||
}: InfoCardProps) {
|
||||
return (
|
||||
<InputLayout
|
||||
className={clsx(
|
||||
role === 'alert' ? 'bg-error' : 'bg-gray-100 dark:bg-slate-800',
|
||||
className,
|
||||
)}
|
||||
icon={
|
||||
<AlertIcon
|
||||
className={clsx(
|
||||
'fill-current h-4 w-4',
|
||||
role === 'alert' ? 'text-white' : 'text-brand',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'py-2 overflow-hidden',
|
||||
role === 'alert' ? 'text-white' : undefined,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</InputLayout>
|
||||
)
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { InputHTMLAttributes, useRef, useState } from 'react'
|
||||
import { InputContainer } from './input-container'
|
||||
|
||||
const generateUniqueId = () => Math.random().toString(36).slice(2)
|
||||
|
||||
export type InputCheckboxProps = Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
'type'
|
||||
>
|
||||
|
||||
export function InputCheckbox({
|
||||
id,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InputCheckboxProps) {
|
||||
const [htmlFor] = useState(generateUniqueId)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<InputContainer
|
||||
id={id}
|
||||
ref={ref}
|
||||
icon={
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
id={htmlFor}
|
||||
className="text-brand outline-none"
|
||||
type="checkbox"
|
||||
/>
|
||||
}
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
if (event.target === ref.current && !event.defaultPrevented) {
|
||||
inputRef.current?.click()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor={htmlFor} className="block w-full leading-[1.6]">
|
||||
{children}
|
||||
</label>
|
||||
</InputContainer>
|
||||
)
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
import { forwardRef, useState } from 'react'
|
||||
import { clsx } from '../lib/clsx'
|
||||
import { InputLayout, InputLayoutProps } from './input-layout'
|
||||
|
||||
export type InputContainerProps = InputLayoutProps
|
||||
|
||||
export const InputContainer = forwardRef<HTMLDivElement, InputContainerProps>(
|
||||
({ className, onFocus, icon, onBlur, ...props }, ref) => {
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
return (
|
||||
<InputLayout
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
// Background
|
||||
'bg-gray-100 has-[:focus]:bg-slate-200',
|
||||
'dark:bg-slate-800 dark:has-[:focus]:bg-slate-700',
|
||||
// Border
|
||||
'outline-none',
|
||||
'border-solid border-2 border-transparent hover:border-gray-400 has-[:focus]:border-brand hover:has-[:focus]:border-brand',
|
||||
'dark:hover:border-gray-500',
|
||||
className,
|
||||
)}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event)
|
||||
if (!event.defaultPrevented) setFocused(true)
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event)
|
||||
if (!event.defaultPrevented) setFocused(false)
|
||||
}}
|
||||
icon={<div className={focused ? 'text-brand' : undefined}>{icon}</div>}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
@ -1,47 +0,0 @@
|
||||
import { forwardRef, HTMLAttributes, ReactNode } from 'react'
|
||||
import { clsx } from '../lib/clsx'
|
||||
import { Override } from '../lib/util'
|
||||
|
||||
export type InputLayoutProps = Override<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
{
|
||||
icon?: ReactNode
|
||||
append?: ReactNode
|
||||
}
|
||||
>
|
||||
|
||||
export const InputLayout = forwardRef<HTMLDivElement, InputLayoutProps>(
|
||||
({ className, icon, append, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
// Layout
|
||||
'pl-1 pr-2', // Less padding on the left because icon will provide some
|
||||
'min-h-12',
|
||||
'flex items-center justify-stretch',
|
||||
// Border
|
||||
'rounded-lg',
|
||||
// Font
|
||||
'text-gray-700',
|
||||
'dark:text-gray-100',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'self-start shrink-0 grow-0',
|
||||
'w-8 h-12',
|
||||
'flex items-center justify-center',
|
||||
'text-gray-500',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-auto relative">{children}</div>
|
||||
{append && <div className="grow-0 shrink-0">{append}</div>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
@ -1,69 +0,0 @@
|
||||
import {
|
||||
forwardRef,
|
||||
InputHTMLAttributes,
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { InputContainer } from './input-container'
|
||||
|
||||
export const InputText = forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
icon?: ReactNode
|
||||
} & InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, icon, children, onFocus, onBlur, ...props }, ref) => {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useImperativeHandle(ref, () => inputRef.current!, [])
|
||||
|
||||
const handleClick = useCallback<MouseEventHandler<HTMLDivElement>>(
|
||||
(event) => {
|
||||
if (inputRef.current !== event.target) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback<MouseEventHandler<HTMLDivElement>>(
|
||||
(event) => {
|
||||
if (focused && event.target !== inputRef.current) {
|
||||
// Prevent "blur" event from firing when clicking outside the input
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
[focused],
|
||||
)
|
||||
|
||||
return (
|
||||
<InputContainer
|
||||
icon={icon}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder-gray-500"
|
||||
onFocus={(event) => {
|
||||
setFocused(true)
|
||||
onFocus?.(event)
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setFocused(false)
|
||||
onBlur?.(event)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{children}
|
||||
</InputContainer>
|
||||
)
|
||||
})
|
@ -1,60 +0,0 @@
|
||||
import { HTMLAttributes, ReactNode } from 'react'
|
||||
import { clsx } from '../lib/clsx'
|
||||
import { Override } from '../lib/util'
|
||||
|
||||
export type LayoutTitlePageProps = Override<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
{
|
||||
title?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
}
|
||||
>
|
||||
|
||||
export function LayoutTitlePage({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
...props
|
||||
}: LayoutTitlePageProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'flex flex-col items-center',
|
||||
'md:flex md:flex-row md:justify-stretch md:items-center',
|
||||
'min-h-screen min-w-screen',
|
||||
'bg-white text-slate-900',
|
||||
'dark:bg-slate-900 dark:text-slate-100',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'px-6 pt-4',
|
||||
'md:max-w-lg',
|
||||
'md:grid md:content-center md:justify-items-end',
|
||||
'md:self-stretch',
|
||||
'md:w-1/2 md:max-w-fix md:p-4',
|
||||
'md:text-right',
|
||||
'md:dark:border-r md:dark:border-slate-700',
|
||||
'md:bg-slate-100 md:dark:bg-slate-800',
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<h1 className="text-xl md:text-2xl lg:text-5xl md:mt-4 mb-4 font-semibold text-brand">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{subtitle && (
|
||||
<p className="hidden md:block max-w-xs text-slate-500 dark:text-slate-500">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full px-6 md:max-w-3xl md:px-12">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
import { HTMLAttributes } from 'react'
|
||||
import { Override } from '../lib/util'
|
||||
import { clsx } from '../lib/clsx'
|
||||
|
||||
export type LayoutWelcomeProps = Override<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
{
|
||||
name?: string
|
||||
logo?: string
|
||||
links?: Array<{
|
||||
title: string
|
||||
href: string
|
||||
rel?: string
|
||||
}>
|
||||
logoAlt?: string
|
||||
}
|
||||
>
|
||||
|
||||
export function LayoutWelcome({
|
||||
name,
|
||||
logo,
|
||||
logoAlt = name || 'Logo',
|
||||
links,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: LayoutWelcomeProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'min-h-screen w-full',
|
||||
'flex items-center justify-center flex-col',
|
||||
'bg-white text-slate-900',
|
||||
'dark:bg-slate-900 dark:text-slate-100',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="w-full max-w-screen-sm overflow-hidden flex-grow flex flex-col items-center justify-center">
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt={logoAlt}
|
||||
className="w-16 h-16 md:w-24 md:h-24 mb-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{name && (
|
||||
<h1 className="text-2xl md:text-4xl mb-8 mx-4 text-center font-bold">
|
||||
{name}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{links != null && links.length > 0 && (
|
||||
<nav className="w-full max-w-screen-sm overflow-hidden mt-4 border-t border-t-slate-200 dark:border-t-slate-700 flex flex-wrap justify-center">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
rel={link.rel}
|
||||
target="_blank"
|
||||
className="m-2 md:m-4 text-xs md:text-sm text-brand hover:underline"
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
import { JSX, ReactNode } from 'react'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { LocaleSelector } from '../../locales/locale-selector.tsx'
|
||||
|
||||
export type LayoutTitlePageProps = Override<
|
||||
JSX.IntrinsicElements['div'],
|
||||
{
|
||||
title?: string
|
||||
subtitle?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
>
|
||||
|
||||
export function LayoutTitlePage({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
|
||||
// HTMLDivElement
|
||||
className,
|
||||
...props
|
||||
}: LayoutTitlePageProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
className,
|
||||
'flex flex-col items-center',
|
||||
'md:flex md:flex-row md:justify-stretch md:items-center',
|
||||
'min-h-screen min-w-screen',
|
||||
'bg-white text-slate-900',
|
||||
'dark:bg-slate-900 dark:text-slate-100',
|
||||
)}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'px-6 pt-4',
|
||||
'w-full',
|
||||
'md:max-w-lg',
|
||||
'flex flex-row md:flex-col',
|
||||
'md:self-stretch',
|
||||
'md:w-1/2 md:max-w-fix md:p-4',
|
||||
'md:text-right',
|
||||
'md:dark:border-r md:dark:border-slate-700',
|
||||
'md:bg-slate-100 md:dark:bg-slate-800',
|
||||
)}
|
||||
>
|
||||
<div className="flex-grow grid content-center md:justify-items-end">
|
||||
{title && (
|
||||
<h1
|
||||
key="title"
|
||||
className="text-xl md:text-2xl lg:text-5xl md:my-4 font-semibold text-brand"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{subtitle && (
|
||||
<p
|
||||
key="subtitle"
|
||||
className="hidden md:block max-w-xs text-slate-600 dark:text-slate-400"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LocaleSelector key="localeSelector" className="m-1 md:m-2" />
|
||||
</div>
|
||||
|
||||
<main className="w-full p-6 md:max-w-3xl md:px-12">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
import { JSX } from 'react'
|
||||
import { CustomizationData } from '../../backend-types.ts'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { LocaleSelector } from '../../locales/locale-selector.tsx'
|
||||
import { LinkAnchor } from '../utils/link-anchor.tsx'
|
||||
|
||||
export type LayoutWelcomeProps = Override<
|
||||
JSX.IntrinsicElements['div'],
|
||||
{
|
||||
customizationData: CustomizationData | undefined
|
||||
title?: string
|
||||
}
|
||||
>
|
||||
|
||||
export function LayoutWelcome({
|
||||
customizationData: { logo, name, links } = {},
|
||||
title = name,
|
||||
|
||||
// div
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: LayoutWelcomeProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
'min-h-screen w-full',
|
||||
'flex items-center justify-center flex-col',
|
||||
'bg-white text-slate-900',
|
||||
'dark:bg-slate-900 dark:text-slate-100',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
|
||||
<main className="w-full overflow-hidden flex-grow flex flex-col items-center justify-center p-6">
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt={name || `Logo`}
|
||||
aria-hidden
|
||||
className="w-16 h-16 md:w-24 md:h-24 mb-4 md:mb-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{name && (
|
||||
<h1 className="text-2xl md:text-4xl mb-4 md:mb-8 mx-4 text-center font-bold">
|
||||
{name}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<nav className="w-full overflow-hidden border-t border-t-slate-200 dark:border-t-slate-700 flex flex-wrap justify-center content-center">
|
||||
{links?.map((link, i) => (
|
||||
<LinkAnchor
|
||||
key={i}
|
||||
link={link}
|
||||
className="m-2 md:m-4 text-xs md:text-sm text-brand hover:underline"
|
||||
/>
|
||||
))}
|
||||
|
||||
<LocaleSelector
|
||||
className="m-1 md:m-2 text-xs md:text-sm"
|
||||
key="localeSelector"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,337 +0,0 @@
|
||||
import { ReactNode, SyntheticEvent, useCallback, useState } from 'react'
|
||||
|
||||
import {
|
||||
InvalidCredentialsError,
|
||||
SecondAuthenticationFactorRequiredError,
|
||||
} from '../lib/api'
|
||||
import { clsx } from '../lib/clsx'
|
||||
import { Override } from '../lib/util'
|
||||
import { Button } from './button'
|
||||
import { FormCard, FormCardProps } from './form-card'
|
||||
import { AtSymbolIcon } from './icons/at-symbol-icon'
|
||||
import { LockIcon } from './icons/lock-icon'
|
||||
import { InfoCard } from './info-card'
|
||||
import { InputCheckbox } from './input-checkbox'
|
||||
import { InputText } from './input-text'
|
||||
import { TokenIcon } from './icons/token-icon'
|
||||
import { Fieldset } from './fieldset'
|
||||
|
||||
export type SignInFormOutput = {
|
||||
username: string
|
||||
password: string
|
||||
remember?: boolean
|
||||
}
|
||||
|
||||
export type SignInFormProps = Override<
|
||||
FormCardProps,
|
||||
{
|
||||
onSubmit: (credentials: SignInFormOutput) => void | PromiseLike<void>
|
||||
submitLabel?: ReactNode
|
||||
submitAria?: string
|
||||
|
||||
onCancel?: () => void
|
||||
cancelLabel?: ReactNode
|
||||
cancelAria?: string
|
||||
|
||||
accountSection?: ReactNode
|
||||
sessionSection?: ReactNode
|
||||
secondFactorSection?: ReactNode
|
||||
|
||||
usernameDefault?: string
|
||||
usernameReadonly?: boolean
|
||||
usernameLabel?: string
|
||||
usernamePlaceholder?: string
|
||||
usernameAria?: string
|
||||
usernamePattern?: string
|
||||
usernameFormat?: string
|
||||
|
||||
passwordLabel?: string
|
||||
passwordPlaceholder?: string
|
||||
passwordWarning?: ReactNode
|
||||
passwordAria?: string
|
||||
passwordPattern?: string
|
||||
passwordFormat?: string
|
||||
|
||||
secondFactorLabel?: string
|
||||
secondFactorPlaceholder?: string
|
||||
secondFactorAria?: string
|
||||
secondFactorPattern?: string
|
||||
secondFactorFormat?: string
|
||||
secondFactorHint?: string
|
||||
secondFactorParseValue?: (value: string) => string | false
|
||||
|
||||
rememberVisible?: boolean
|
||||
rememberDefault?: boolean
|
||||
rememberLabel?: string
|
||||
rememberAria?: string
|
||||
}
|
||||
>
|
||||
|
||||
export function SignInForm({
|
||||
onSubmit,
|
||||
submitAria = 'Next',
|
||||
submitLabel = submitAria,
|
||||
|
||||
onCancel = undefined,
|
||||
cancelAria = 'Cancel',
|
||||
cancelLabel = cancelAria,
|
||||
|
||||
accountSection = 'Account',
|
||||
sessionSection = 'Session',
|
||||
secondFactorSection = '2FA Confirmation',
|
||||
|
||||
usernameDefault = '',
|
||||
usernameReadonly = false,
|
||||
usernameLabel = 'Username or email address',
|
||||
usernameAria = usernameLabel,
|
||||
usernamePlaceholder = usernameLabel,
|
||||
usernamePattern = undefined,
|
||||
usernameFormat = 'valid email address or username',
|
||||
|
||||
passwordLabel = 'Password',
|
||||
passwordAria = passwordLabel,
|
||||
passwordPlaceholder = passwordLabel,
|
||||
passwordPattern = undefined,
|
||||
passwordFormat = 'non empty string',
|
||||
passwordWarning = (
|
||||
<>
|
||||
<p className="font-bold text-brand leading-8">Warning</p>
|
||||
<p>
|
||||
Please verify the domain name of the website before entering your
|
||||
password. Never enter your password on a domain you do not trust.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
|
||||
secondFactorLabel = 'Confirmation code',
|
||||
secondFactorAria = secondFactorLabel,
|
||||
secondFactorPlaceholder = secondFactorLabel,
|
||||
secondFactorPattern = '^[A-Z2-7]{5}-[A-Z2-7]{5}$',
|
||||
secondFactorFormat = 'XXXXX-XXXXX',
|
||||
secondFactorHint = 'Check your $1 email for a login code and enter it here.',
|
||||
secondFactorParseValue = checkAndFormatEmailOtpCode,
|
||||
|
||||
rememberVisible = true,
|
||||
rememberDefault = false,
|
||||
rememberLabel = 'Remember this account on this device',
|
||||
rememberAria = rememberLabel,
|
||||
|
||||
...props
|
||||
}: SignInFormProps) {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [secondFactor, setSecondFactor] = useState<null | {
|
||||
type: 'emailOtp'
|
||||
hint: string
|
||||
}>(null)
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setSecondFactor(null)
|
||||
setErrorMessage(null)
|
||||
}, [])
|
||||
|
||||
const passwordReadonly = secondFactor != null
|
||||
|
||||
const doSubmit = useCallback(
|
||||
async (
|
||||
event: SyntheticEvent<
|
||||
HTMLFormElement & {
|
||||
username: HTMLInputElement
|
||||
password: HTMLInputElement
|
||||
remember?: HTMLInputElement
|
||||
secondFactor?: HTMLInputElement
|
||||
},
|
||||
SubmitEvent
|
||||
>,
|
||||
) => {
|
||||
event.preventDefault()
|
||||
|
||||
const credentials: SignInFormOutput = {
|
||||
username: event.currentTarget.username.value,
|
||||
password: event.currentTarget.password.value,
|
||||
remember: event.currentTarget.remember?.checked,
|
||||
}
|
||||
|
||||
if (secondFactor) {
|
||||
const element = event.currentTarget.secondFactor
|
||||
if (!element) throw new Error('Second factor input not found')
|
||||
const value = secondFactorParseValue(element.value)
|
||||
if (!value) {
|
||||
setSecondFactor({
|
||||
type: secondFactor.type,
|
||||
hint: `Make sure to match the format: ${secondFactorFormat}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
credentials[secondFactor.type] = value
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
await onSubmit(credentials)
|
||||
} catch (err) {
|
||||
if (err instanceof SecondAuthenticationFactorRequiredError) {
|
||||
setSecondFactor({
|
||||
type: err.type,
|
||||
hint: err.hint,
|
||||
})
|
||||
} else {
|
||||
setErrorMessage(parseErrorMessage(err))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[secondFactor, onSubmit],
|
||||
)
|
||||
|
||||
return (
|
||||
<FormCard
|
||||
onSubmit={doSubmit}
|
||||
error={errorMessage}
|
||||
cancel={
|
||||
onCancel && (
|
||||
<Button aria-label={cancelAria} onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
color="brand"
|
||||
type="submit"
|
||||
aria-label={submitAria}
|
||||
loading={loading}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Fieldset title={accountSection} disabled={loading}>
|
||||
<InputText
|
||||
icon={<AtSymbolIcon className="w-5" />}
|
||||
name="username"
|
||||
type="text"
|
||||
onChange={resetState}
|
||||
placeholder={usernamePlaceholder}
|
||||
aria-label={usernameAria}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoComplete="username"
|
||||
spellCheck="false"
|
||||
dir="auto"
|
||||
enterKeyHint="next"
|
||||
required
|
||||
defaultValue={usernameDefault}
|
||||
readOnly={usernameReadonly}
|
||||
disabled={usernameReadonly}
|
||||
pattern={usernamePattern}
|
||||
title={usernameFormat}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
icon={<LockIcon className="w-5" />}
|
||||
name="password"
|
||||
type="password"
|
||||
onChange={resetState}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setTimeout(setFocused, 100, false)}
|
||||
placeholder={passwordPlaceholder}
|
||||
aria-label={passwordAria}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoComplete="current-password"
|
||||
dir="auto"
|
||||
enterKeyHint="done"
|
||||
spellCheck="false"
|
||||
required
|
||||
readOnly={passwordReadonly}
|
||||
disabled={passwordReadonly}
|
||||
pattern={passwordPattern}
|
||||
title={passwordFormat}
|
||||
/>
|
||||
|
||||
{passwordWarning && (
|
||||
<div
|
||||
className={clsx(
|
||||
'transition-all delay-300 duration-300 overflow-hidden',
|
||||
focused ? 'max-h-80' : 'max-h-0 -z-10 !mt-0',
|
||||
)}
|
||||
>
|
||||
<InfoCard role="status">{passwordWarning}</InfoCard>
|
||||
</div>
|
||||
)}
|
||||
</Fieldset>
|
||||
|
||||
{rememberVisible && (
|
||||
<Fieldset key="remember" title={sessionSection} disabled={loading}>
|
||||
<InputCheckbox
|
||||
name="remember"
|
||||
defaultChecked={rememberDefault}
|
||||
aria-label={rememberAria}
|
||||
>
|
||||
{rememberLabel}
|
||||
</InputCheckbox>
|
||||
</Fieldset>
|
||||
)}
|
||||
|
||||
{secondFactor && (
|
||||
<Fieldset key="2fa" title={secondFactorSection} disabled={loading}>
|
||||
<div>
|
||||
<InputText
|
||||
icon={<TokenIcon className="w-5" />}
|
||||
name="secondFactor"
|
||||
type="text"
|
||||
placeholder={secondFactorPlaceholder}
|
||||
aria-label={secondFactorAria}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
dir="auto"
|
||||
enterKeyHint="done"
|
||||
required
|
||||
pattern={secondFactorPattern}
|
||||
title={secondFactorFormat}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<p className="text-slate-600 dark:text-slate-400 text-sm">
|
||||
{secondFactorHint.replaceAll('$1', secondFactor.hint)}
|
||||
</p>
|
||||
</div>
|
||||
</Fieldset>
|
||||
)}
|
||||
</FormCard>
|
||||
)
|
||||
}
|
||||
|
||||
function parseErrorMessage(err: unknown): string {
|
||||
if (err instanceof InvalidCredentialsError) {
|
||||
return 'Invalid username or password'
|
||||
}
|
||||
|
||||
return 'An unknown error occurred'
|
||||
}
|
||||
|
||||
export function checkAndFormatEmailOtpCode(code: string): string | false {
|
||||
const EMAIL_CODE_REGEX = /^[A-Z2-7]{5}-[A-Z2-7]{5}$/
|
||||
|
||||
// Trim the reset code
|
||||
let fixed = code.trim().toUpperCase()
|
||||
|
||||
// Add a dash if needed
|
||||
if (fixed.length === 10) {
|
||||
fixed = `${fixed.slice(0, 5)}-${fixed.slice(5, 10)}`
|
||||
}
|
||||
|
||||
// Check that it is a valid format
|
||||
if (!EMAIL_CODE_REGEX.test(fixed)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return fixed
|
||||
}
|
@ -1,194 +0,0 @@
|
||||
import {
|
||||
FormHTMLAttributes,
|
||||
ReactNode,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { Button } from './button'
|
||||
import { FormCard, FormCardProps } from './form-card'
|
||||
import { Override } from '../lib/util'
|
||||
import { Fieldset } from './fieldset'
|
||||
|
||||
export type SignUpAccountFormOutput = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type SignUpAccountFormProps = Override<
|
||||
FormCardProps,
|
||||
{
|
||||
onSubmit: (credentials: SignUpAccountFormOutput) => void | PromiseLike<void>
|
||||
submitLabel?: ReactNode
|
||||
submitAria?: string
|
||||
|
||||
onCancel?: () => void
|
||||
cancelLabel?: ReactNode
|
||||
cancelAria?: string
|
||||
|
||||
username?: string
|
||||
usernamePlaceholder?: string
|
||||
usernameLabel?: string
|
||||
usernameAria?: string
|
||||
usernamePattern?: string
|
||||
usernameTitle?: string
|
||||
|
||||
passwordPlaceholder?: string
|
||||
passwordLabel?: string
|
||||
passwordAria?: string
|
||||
passwordPattern?: string
|
||||
passwordTitle?: string
|
||||
}
|
||||
>
|
||||
|
||||
export function SignUpAccountForm({
|
||||
onSubmit,
|
||||
submitAria = 'Next',
|
||||
submitLabel = submitAria,
|
||||
|
||||
onCancel = undefined,
|
||||
cancelAria = 'Cancel',
|
||||
cancelLabel = cancelAria,
|
||||
|
||||
username: defaultUsername = '',
|
||||
usernameLabel = 'Username',
|
||||
usernameAria = usernameLabel,
|
||||
usernamePlaceholder = usernameLabel,
|
||||
usernamePattern,
|
||||
usernameTitle,
|
||||
|
||||
passwordLabel = 'Password',
|
||||
passwordAria = passwordLabel,
|
||||
passwordPlaceholder = passwordLabel,
|
||||
passwordPattern,
|
||||
passwordTitle,
|
||||
|
||||
children,
|
||||
...props
|
||||
}: SignUpAccountFormProps &
|
||||
Omit<FormHTMLAttributes<HTMLFormElement>, keyof SignUpAccountFormProps>) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
const doSubmit = useCallback(
|
||||
async (
|
||||
event: SyntheticEvent<
|
||||
HTMLFormElement & {
|
||||
username: HTMLInputElement
|
||||
password: HTMLInputElement
|
||||
},
|
||||
SubmitEvent
|
||||
>,
|
||||
) => {
|
||||
event.preventDefault()
|
||||
|
||||
const credentials = {
|
||||
username: event.currentTarget.username.value,
|
||||
password: event.currentTarget.password.value,
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
await onSubmit(credentials)
|
||||
} catch (err) {
|
||||
setErrorMessage(parseErrorMessage(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[onSubmit, setErrorMessage, setLoading],
|
||||
)
|
||||
|
||||
return (
|
||||
<FormCard
|
||||
append={children}
|
||||
error={errorMessage}
|
||||
onSubmit={doSubmit}
|
||||
cancel={
|
||||
onCancel && (
|
||||
<Button aria-label={cancelAria} onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
color="brand"
|
||||
type="submit"
|
||||
aria-label={submitAria}
|
||||
disabled={loading}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<Fieldset disabled={loading}>
|
||||
<label className="text-sm font-medium block">
|
||||
<p>{usernameLabel}</p>
|
||||
|
||||
<div className="relative flex flex-wrap items-center justify-stretch rounded-md border border-solid border-slate-200 dark:border-slate-700 text-neutral-700 dark:text-neutral-100">
|
||||
<span className="w-6 ml-1 text-center text-base">@</span>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
onChange={() => setErrorMessage(null)}
|
||||
className="relative m-1 block w-[1px] min-w-0 flex-auto leading-[1.6] bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder:text-neutral-100 disabled:text-gray-500"
|
||||
placeholder={usernamePlaceholder}
|
||||
aria-label={usernameAria}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoComplete="username"
|
||||
spellCheck="false"
|
||||
dir="auto"
|
||||
enterKeyHint="next"
|
||||
required
|
||||
defaultValue={defaultUsername}
|
||||
pattern={usernamePattern}
|
||||
title={usernameTitle}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="text-sm font-medium block">
|
||||
<p>{passwordLabel}</p>
|
||||
|
||||
<div className="relative flex flex-wrap items-center justify-stretch rounded-md border border-solid border-slate-200 dark:border-slate-700 text-neutral-700 dark:text-neutral-100">
|
||||
<span className="w-6 ml-1 text-center text-2xl font-light -mb-2">
|
||||
*
|
||||
</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
onChange={() => setErrorMessage(null)}
|
||||
className="relative m-1 block w-[1px] min-w-0 flex-auto leading-[1.6] bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder:text-neutral-100"
|
||||
placeholder={passwordPlaceholder}
|
||||
aria-label={passwordAria}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoComplete="new-password"
|
||||
dir="auto"
|
||||
enterKeyHint="done"
|
||||
spellCheck="false"
|
||||
required
|
||||
pattern={passwordPattern}
|
||||
title={passwordTitle}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</Fieldset>
|
||||
</FormCard>
|
||||
)
|
||||
}
|
||||
|
||||
function parseErrorMessage(err: unknown): string {
|
||||
switch ((err as any)?.message) {
|
||||
case 'Invalid credentials':
|
||||
return 'Invalid username or password'
|
||||
default:
|
||||
console.error(err)
|
||||
return 'An unknown error occurred'
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
import { HTMLAttributes } from 'react'
|
||||
import { LinkDefinition } from '../backend-data'
|
||||
import { clsx } from '../lib/clsx'
|
||||
|
||||
export type SignUpDisclaimerProps = {
|
||||
links?: readonly LinkDefinition[]
|
||||
}
|
||||
|
||||
export function SignUpDisclaimer({
|
||||
links,
|
||||
|
||||
className,
|
||||
...attrs
|
||||
}: SignUpDisclaimerProps &
|
||||
Omit<
|
||||
HTMLAttributes<HTMLParagraphElement>,
|
||||
keyof SignUpDisclaimerProps | 'children'
|
||||
>) {
|
||||
const relevantLinks = links?.filter(
|
||||
(l) => l.rel === 'privacy-policy' || l.rel === 'terms-of-service',
|
||||
)
|
||||
|
||||
return (
|
||||
<p className={clsx('text-sm text-slate-500', className)} {...attrs}>
|
||||
By creating an account you agree to the{' '}
|
||||
{relevantLinks && relevantLinks.length
|
||||
? relevantLinks.map((l, i, a) => (
|
||||
<span key={i}>
|
||||
{i > 0 && (i < a.length - 1 ? ', ' : ' and ')}
|
||||
<a
|
||||
href={l.href}
|
||||
rel={l.rel}
|
||||
target="_blank"
|
||||
className="text-brand underline"
|
||||
>
|
||||
{l.title}
|
||||
</a>
|
||||
</span>
|
||||
))
|
||||
: 'Terms of Service and Privacy Policy'}
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { JSX } from 'react'
|
||||
import { Account } from '../../backend-types.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type AccountIdentifierProps = Override<
|
||||
Omit<JSX.IntrinsicElements['b'], 'children'>,
|
||||
{
|
||||
account: Account
|
||||
}
|
||||
>
|
||||
|
||||
export function AccountIdentifier({
|
||||
account,
|
||||
|
||||
// b
|
||||
...props
|
||||
}: AccountIdentifierProps) {
|
||||
return (
|
||||
<b {...props}>
|
||||
{account.preferred_username || account.email || account.sub}
|
||||
</b>
|
||||
)
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AccountIcon } from './icons.tsx'
|
||||
|
||||
export type AccountIconProps = {
|
||||
src?: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
export function AccountImage({ src, alt }: AccountIconProps) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false)
|
||||
}, [src])
|
||||
|
||||
return src && !errored ? (
|
||||
<img
|
||||
aria-hidden
|
||||
crossOrigin="anonymous"
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="-ml-1 w-6 h-6 rounded-full"
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-6 w-6 text-white bg-brand rounded-full border-solid border-2 border-brand overflow-hidden"
|
||||
>
|
||||
<AccountIcon className="-mx-1 -mb-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
import { JSX, memo } from 'react'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { AlertIcon, EyeIcon } from './icons.tsx'
|
||||
|
||||
export type AdmonitionProps = Override<
|
||||
JSX.IntrinsicElements['div'],
|
||||
{
|
||||
role: 'alert' | 'status' | 'info'
|
||||
}
|
||||
>
|
||||
|
||||
export const Admonition = memo(function Admonition({
|
||||
role = 'alert',
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: AdmonitionProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
role={role}
|
||||
className={clsx(
|
||||
'flex flex-row',
|
||||
'gap-2',
|
||||
'p-3',
|
||||
'rounded-lg',
|
||||
'border',
|
||||
'border-gray-300 dark:border-gray-700',
|
||||
role === 'alert' && 'bg-error text-error-c',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{role === 'info' ? (
|
||||
<EyeIcon
|
||||
aria-hidden
|
||||
className={clsx('fill-current h-6 w-6', 'text-brand')}
|
||||
/>
|
||||
) : (
|
||||
<AlertIcon
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
'fill-current h-6 w-6',
|
||||
role === 'alert' ? 'text-inherit' : 'text-brand',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
@ -0,0 +1,45 @@
|
||||
import { Trans } from '@lingui/react/macro'
|
||||
import { JSX } from 'react'
|
||||
import type { OAuthClientMetadata } from '@atproto/oauth-types'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { UrlViewer } from './url-viewer.tsx'
|
||||
|
||||
export type ClientNameProps = Override<
|
||||
Omit<JSX.IntrinsicElements['span'], 'children'>,
|
||||
{
|
||||
clientId: string
|
||||
clientMetadata: OAuthClientMetadata
|
||||
clientTrusted: boolean
|
||||
}
|
||||
>
|
||||
|
||||
export function ClientName({
|
||||
clientId,
|
||||
clientMetadata,
|
||||
clientTrusted,
|
||||
|
||||
// span
|
||||
...attrs
|
||||
}: ClientNameProps) {
|
||||
if (clientTrusted && clientMetadata.client_name) {
|
||||
return <span {...attrs}>{clientMetadata.client_name}</span>
|
||||
}
|
||||
|
||||
// @NOTE: not using isOAuthClientIdLoopback & isOAuthClientIdDiscoverable from
|
||||
// @atproto/oauth-types here because 1) we don't need to validate here and 2)
|
||||
// we prefer not to import un-necessary code to improve bundle size.
|
||||
|
||||
if (clientId.startsWith('http://')) {
|
||||
return (
|
||||
<span {...attrs}>
|
||||
<Trans>An application on your device</Trans>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (clientId.startsWith('https://')) {
|
||||
return <UrlViewer {...attrs} url={clientId} path />
|
||||
}
|
||||
|
||||
return <span {...attrs}>{clientId}</span>
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
import { Trans } from '@lingui/react/macro'
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import { useRandomString } from '../../hooks/use-random-string.ts'
|
||||
import { Api } from '../../lib/api.ts'
|
||||
import { JsonErrorResponse } from '../../lib/json-client.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { Admonition, AdmonitionProps } from './admonition.tsx'
|
||||
import { ErrorMessage } from './error-message.tsx'
|
||||
|
||||
export type ErrorCardProps = Override<
|
||||
Omit<AdmonitionProps, 'role'>,
|
||||
{
|
||||
error: unknown
|
||||
}
|
||||
>
|
||||
export const ErrorCard = memo(function ErrorCard({
|
||||
error,
|
||||
|
||||
// Admonition
|
||||
children,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: ErrorCardProps) {
|
||||
const [inputCount, setInputCount] = useState(0)
|
||||
// Every 5th input will toggle showing the details
|
||||
const showDetails = ((inputCount / 5) | 0) % 2 === 1
|
||||
|
||||
const detailsDivId = useRandomString('error-card-')
|
||||
|
||||
const parsedError = useMemo(
|
||||
() =>
|
||||
error instanceof JsonErrorResponse
|
||||
? // Already parsed:
|
||||
error
|
||||
: // If "error" is a json object, try parsing it as a JsonErrorResponse:
|
||||
Api.parseError(error) ?? error,
|
||||
[error],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// For debugging purposes
|
||||
console.warn('Displayed error details:', parsedError)
|
||||
|
||||
// Reset the input count when the error changes
|
||||
setInputCount(0)
|
||||
}, [parsedError])
|
||||
|
||||
return (
|
||||
<Admonition
|
||||
role="alert"
|
||||
aria-controls={detailsDivId}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
onKeyDown?.(event)
|
||||
if (!event.defaultPrevented) {
|
||||
setInputCount((c) => c + 1)
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
if (!event.defaultPrevented) {
|
||||
setInputCount((c) => c + 1)
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<ErrorMessage error={parsedError} />
|
||||
|
||||
{children && <div className="mt-2">{children}</div>}
|
||||
|
||||
<div hidden={!showDetails} id={detailsDivId} aria-hidden={!showDetails}>
|
||||
{parsedError instanceof JsonErrorResponse ? (
|
||||
<dl className="mt-2 grid grid-cols-[auto,1fr] gap-x-2 text-sm">
|
||||
<dt className="font-semibold">
|
||||
<Trans>Code</Trans>
|
||||
</dt>
|
||||
<dd>
|
||||
<code>{parsedError.error}</code>
|
||||
</dd>
|
||||
|
||||
<dt className="font-semibold">
|
||||
<Trans>Description</Trans>
|
||||
</dt>
|
||||
<dd>{parsedError.description}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<pre className="text-xs">{JSON.stringify(parsedError, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
})
|
@ -0,0 +1,62 @@
|
||||
import { Trans } from '@lingui/react/macro'
|
||||
import { ReactNode, memo } from 'react'
|
||||
import {
|
||||
EmailTakenError,
|
||||
HandleUnavailableError,
|
||||
InvalidCredentialsError,
|
||||
RequestExpiredError,
|
||||
SecondAuthenticationFactorRequiredError,
|
||||
UnknownRequestUriError,
|
||||
} from '../../lib/api.ts'
|
||||
import { JsonErrorResponse } from '../../lib/json-client.ts'
|
||||
|
||||
export type ApiErrorMessageProps = {
|
||||
error: unknown
|
||||
}
|
||||
|
||||
export const ErrorMessage = memo(function ErrorMessage({
|
||||
error,
|
||||
}: ApiErrorMessageProps): ReactNode {
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
return <Trans>Wrong identifier or password</Trans>
|
||||
}
|
||||
|
||||
if (error instanceof EmailTakenError) {
|
||||
return <Trans>This email is already used</Trans>
|
||||
}
|
||||
|
||||
if (error instanceof HandleUnavailableError) {
|
||||
switch (error.reason) {
|
||||
case 'syntax':
|
||||
return <Trans>The handle is invalid</Trans>
|
||||
case 'domain':
|
||||
return <Trans>The domain name is not allowed</Trans>
|
||||
case 'slur':
|
||||
return <Trans>The handle contains inappropriate language</Trans>
|
||||
case 'taken':
|
||||
if (error.description === 'Reserved handle') {
|
||||
return <Trans>This handle is reserved</Trans>
|
||||
}
|
||||
return <Trans>The handle is already in use</Trans>
|
||||
default:
|
||||
return <Trans>That handle cannot be used</Trans>
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof SecondAuthenticationFactorRequiredError) {
|
||||
return <Trans>A second authentication factor is required</Trans>
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof UnknownRequestUriError ||
|
||||
error instanceof RequestExpiredError
|
||||
) {
|
||||
return <Trans>This sign-in session has expired</Trans>
|
||||
}
|
||||
|
||||
if (error instanceof JsonErrorResponse) {
|
||||
return <Trans>Unexpected server response</Trans>
|
||||
}
|
||||
|
||||
return <Trans>An unknown error occurred</Trans>
|
||||
})
|
@ -0,0 +1,46 @@
|
||||
import { Trans } from '@lingui/react/macro'
|
||||
import { JSX } from 'react'
|
||||
import { LinkDefinition } from '../../backend-types.ts'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type HelpCardProps = Override<
|
||||
Omit<JSX.IntrinsicElements['p'], 'children'>,
|
||||
{
|
||||
links?: readonly LinkDefinition[]
|
||||
}
|
||||
>
|
||||
|
||||
export function HelpCard({
|
||||
links,
|
||||
|
||||
className,
|
||||
...props
|
||||
}: HelpCardProps) {
|
||||
const helpLink = links?.find((l) => l.rel === 'help')
|
||||
|
||||
if (!helpLink) return null
|
||||
|
||||
return (
|
||||
<p
|
||||
{...props}
|
||||
className={clsx(
|
||||
'text-sm rounded-md bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-400 p-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Trans>
|
||||
Having trouble?{' '}
|
||||
<a
|
||||
role="link"
|
||||
href={helpLink.href}
|
||||
rel={helpLink.rel}
|
||||
target="_blank"
|
||||
className="text-brand"
|
||||
>
|
||||
<Trans>Contact support</Trans>
|
||||
</a>
|
||||
</Trans>
|
||||
</p>
|
||||
)
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
import type { FunctionComponent, JSX } from 'react'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type IconProps = Override<
|
||||
Omit<JSX.IntrinsicElements['svg'], 'viewBox' | 'children' | 'xmlns'>,
|
||||
{
|
||||
/**
|
||||
* The title of the icon, used for accessibility.
|
||||
*/
|
||||
title?: string
|
||||
}
|
||||
>
|
||||
|
||||
const makeSvgComponent = (path: string, displayName: string) => {
|
||||
const SvgComponent: FunctionComponent<IconProps> = ({ title, ...props }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
aria-hidden={!title}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d={path}
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
SvgComponent.displayName = displayName
|
||||
return SvgComponent
|
||||
}
|
||||
|
||||
export const AccountIcon = makeSvgComponent(
|
||||
'M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z',
|
||||
'AccountIcon',
|
||||
)
|
||||
|
||||
export const AlertIcon = makeSvgComponent(
|
||||
'M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z',
|
||||
'AlertIcon',
|
||||
)
|
||||
|
||||
export const AtSymbolIcon = makeSvgComponent(
|
||||
'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z',
|
||||
'AtSymbolIcon',
|
||||
)
|
||||
|
||||
export const CaretRightIcon = makeSvgComponent(
|
||||
'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
|
||||
'CaretRightIcon',
|
||||
)
|
||||
|
||||
export const CheckMarkIcon = makeSvgComponent(
|
||||
'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
|
||||
'CheckMarkIcon',
|
||||
)
|
||||
|
||||
export const EmailIcon = makeSvgComponent(
|
||||
'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z',
|
||||
'EmailIcon',
|
||||
)
|
||||
|
||||
export const EyeIcon = makeSvgComponent(
|
||||
'M12 6.5c3.79 0 7.17 2.13 8.82 5.5-1.65 3.37-5.02 5.5-8.82 5.5S4.83 15.37 3.18 12C4.83 8.63 8.21 6.5 12 6.5m0-2C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5m0 5c1.38 0 2.5 1.12 2.5 2.5s-1.12 2.5-2.5 2.5-2.5-1.12-2.5-2.5 1.12-2.5 2.5-2.5m0-2c-2.48 0-4.5 2.02-4.5 4.5s2.02 4.5 4.5 4.5 4.5-2.02 4.5-4.5-2.02-4.5-4.5-4.5',
|
||||
'EyeIcon',
|
||||
)
|
||||
|
||||
export const EyeSlashIcon = makeSvgComponent(
|
||||
'M12 6c3.79 0 7.17 2.13 8.82 5.5-.59 1.22-1.42 2.27-2.41 3.12l1.41 1.41c1.39-1.23 2.49-2.77 3.18-4.53C21.27 7.11 17 4 12 4c-1.27 0-2.49.2-3.64.57l1.65 1.65C10.66 6.09 11.32 6 12 6m-1.07 1.14L13 9.21c.57.25 1.03.71 1.28 1.28l2.07 2.07c.08-.34.14-.7.14-1.07C16.5 9.01 14.48 7 12 7c-.37 0-.72.05-1.07.14M2.01 3.87l2.68 2.68C3.06 7.83 1.77 9.53 1 11.5 2.73 15.89 7 19 12 19c1.52 0 2.98-.29 4.32-.82l3.42 3.42 1.41-1.41L3.42 2.45zm7.5 7.5 2.61 2.61c-.04.01-.08.02-.12.02-1.38 0-2.5-1.12-2.5-2.5 0-.05.01-.08.01-.13m-3.4-3.4 1.75 1.75c-.23.55-.36 1.15-.36 1.78 0 2.48 2.02 4.5 4.5 4.5.63 0 1.23-.13 1.77-.36l.98.98c-.88.24-1.8.38-2.75.38-3.79 0-7.17-2.13-8.82-5.5.7-1.43 1.72-2.61 2.93-3.53',
|
||||
'EyeSlashIcon',
|
||||
)
|
||||
|
||||
export const LockIcon = makeSvgComponent(
|
||||
'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
||||
'LockIcon',
|
||||
)
|
||||
|
||||
export const TokenIcon = makeSvgComponent(
|
||||
'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z',
|
||||
'TokenIcon',
|
||||
)
|
||||
|
||||
export const XMarkIcon = makeSvgComponent(
|
||||
'M4.293 4.293a1 1 0 0 1 1.414 0L12 10.586l6.293-6.293a1 1 0 1 1 1.414 1.414L13.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414L12 13.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L10.586 12 4.293 5.707a1 1 0 0 1 0-1.414Z',
|
||||
'XMarkIcon',
|
||||
)
|
@ -0,0 +1,28 @@
|
||||
import { JSX } from 'react'
|
||||
import { LinkDefinition } from '../../backend-types.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
import { LinkTitle } from './link-title.tsx'
|
||||
|
||||
export type LinkAnchorProps = Override<
|
||||
JSX.IntrinsicElements['a'],
|
||||
{
|
||||
link: LinkDefinition
|
||||
}
|
||||
>
|
||||
export function LinkAnchor({
|
||||
link,
|
||||
|
||||
// a
|
||||
children = <LinkTitle link={link} />,
|
||||
role = 'link',
|
||||
target = '_blank',
|
||||
href = link.href,
|
||||
rel = link.rel,
|
||||
...props
|
||||
}: LinkAnchorProps) {
|
||||
return (
|
||||
<a {...props} role={role} target={target} href={href} rel={rel}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import { Trans } from '@lingui/react/macro'
|
||||
import { LinkDefinition } from '../../backend-types.ts'
|
||||
import { MultiLangString } from './multi-lang-string.tsx'
|
||||
|
||||
export type LinkNameProps = {
|
||||
link: LinkDefinition
|
||||
}
|
||||
|
||||
export function LinkTitle({ link }: LinkNameProps) {
|
||||
return (
|
||||
<MultiLangString
|
||||
value={link.title}
|
||||
fallback={
|
||||
link.rel === 'canonical' ? (
|
||||
<Trans>Home</Trans>
|
||||
) : link.rel === 'privacy-policy' ? (
|
||||
<Trans>Privacy Policy</Trans>
|
||||
) : link.rel === 'terms-of-service' ? (
|
||||
<Trans>Terms of Service</Trans>
|
||||
) : link.rel === 'help' ? (
|
||||
<Trans>Support</Trans>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
import { useLingui } from '@lingui/react/macro'
|
||||
import { ReactNode } from 'react'
|
||||
import type { LocalizedString } from '../../backend-types.ts'
|
||||
|
||||
export type MultiLangStringProps = {
|
||||
value: LocalizedString
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
export function MultiLangString({
|
||||
value,
|
||||
fallback,
|
||||
}: MultiLangStringProps): ReactNode {
|
||||
const { i18n } = useLingui()
|
||||
return (
|
||||
findMatchingString(value, i18n.locale) ??
|
||||
fallback ??
|
||||
(typeof value === 'string' ? value : value.en)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Only returns a string if it matches the desired locale.
|
||||
*/
|
||||
function findMatchingString(
|
||||
value: LocalizedString,
|
||||
locale: string,
|
||||
): string | undefined {
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
// By convention, string values are in english ("en")
|
||||
if (locale.startsWith('en')) return value
|
||||
break
|
||||
|
||||
case 'object': {
|
||||
// Exact match
|
||||
const localeMatch = value[locale]
|
||||
if (typeof localeMatch === 'string') return localeMatch
|
||||
|
||||
// Fallback to language match
|
||||
const lang = locale.split('-')[0]
|
||||
const langMatch = value[lang]
|
||||
if (typeof langMatch === 'string') return langMatch
|
||||
|
||||
// Fallback to any locale from same language (e.g. "pt-PT" -> "pt-BR")
|
||||
for (const k in value) {
|
||||
if (k.startsWith(`${lang}-`)) {
|
||||
const fallbackMatch = value[k]
|
||||
if (typeof fallbackMatch === 'string') return fallbackMatch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro'
|
||||
import { JSX } from 'react'
|
||||
import { PasswordStrength, getPasswordStrength } from '../../lib/password.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type PasswordStrengthLabelProps = Override<
|
||||
Omit<JSX.IntrinsicElements['span'], 'children' | 'aria-label'>,
|
||||
{
|
||||
password: string
|
||||
}
|
||||
>
|
||||
|
||||
export function PasswordStrengthLabel({
|
||||
password,
|
||||
|
||||
// span
|
||||
...props
|
||||
}: PasswordStrengthLabelProps) {
|
||||
const { t } = useLingui()
|
||||
const strength = getPasswordStrength(password)
|
||||
|
||||
return (
|
||||
<span {...props} aria-label={t`Password strength`}>
|
||||
{strength === PasswordStrength.extra ? (
|
||||
<Trans>Extra</Trans>
|
||||
) : strength === PasswordStrength.strong ? (
|
||||
<Trans>Strong</Trans>
|
||||
) : strength === PasswordStrength.moderate ? (
|
||||
<Trans>Moderate</Trans>
|
||||
) : password ? (
|
||||
<Trans>Weak</Trans>
|
||||
) : (
|
||||
<Trans>Missing</Trans>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
import { useLingui } from '@lingui/react/macro'
|
||||
import { JSX } from 'react'
|
||||
import { clsx } from '../../lib/clsx.ts'
|
||||
import { PasswordStrength, getPasswordStrength } from '../../lib/password.ts'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type PasswordStrengthMeterProps = Override<
|
||||
Omit<
|
||||
JSX.IntrinsicElements['div'],
|
||||
| 'children'
|
||||
| 'role'
|
||||
| 'aria-label'
|
||||
| 'aria-valuemin'
|
||||
| 'aria-valuemax'
|
||||
| 'aria-valuenow'
|
||||
>,
|
||||
{
|
||||
password: string
|
||||
}
|
||||
>
|
||||
|
||||
export function PasswordStrengthMeter({
|
||||
password,
|
||||
|
||||
// div
|
||||
className,
|
||||
...props
|
||||
}: PasswordStrengthMeterProps) {
|
||||
const { t } = useLingui()
|
||||
const strength = password ? getPasswordStrength(password) : 0
|
||||
|
||||
const colorBg = 'bg-gray-300 dark:bg-slate-500'
|
||||
const color =
|
||||
strength === PasswordStrength.extra || strength === PasswordStrength.strong
|
||||
? 'bg-success'
|
||||
: strength === PasswordStrength.moderate
|
||||
? 'bg-warning'
|
||||
: 'bg-error'
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx('w-full h-1 flex space-x-2', className)}
|
||||
role="meter"
|
||||
aria-label={t`Password strength indicator`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={PasswordStrength.extra}
|
||||
aria-valuenow={strength}
|
||||
>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded h-1 w-1/4 ${strength > i ? color : colorBg}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { HTMLAttributes, useMemo } from 'react'
|
||||
import { JSX, useMemo } from 'react'
|
||||
import { Override } from '../../lib/util.ts'
|
||||
|
||||
export type UrlPartRenderingOptions = {
|
||||
faded?: boolean
|
||||
@ -12,10 +13,10 @@ export type UrlRendererProps = {
|
||||
path?: boolean | UrlPartRenderingOptions
|
||||
query?: boolean | UrlPartRenderingOptions
|
||||
hash?: boolean | UrlPartRenderingOptions
|
||||
as?: keyof JSX.IntrinsicElements
|
||||
as?: string
|
||||
}
|
||||
|
||||
export function UrlViewer({
|
||||
export function UrlViewer<As extends keyof JSX.IntrinsicElements = 'span'>({
|
||||
url,
|
||||
proto = false,
|
||||
host = true,
|
||||
@ -23,12 +24,14 @@ export function UrlViewer({
|
||||
query = false,
|
||||
hash = false,
|
||||
as: As = 'span',
|
||||
...attrs
|
||||
}: UrlRendererProps & HTMLAttributes<Element>) {
|
||||
|
||||
// Element
|
||||
...props
|
||||
}: Override<JSX.IntrinsicElements[As], UrlRendererProps>) {
|
||||
const urlObj = useMemo(() => new URL(url), [url])
|
||||
|
||||
return (
|
||||
<As {...attrs}>
|
||||
<As {...props}>
|
||||
{proto && (
|
||||
<UrlPartViewer
|
||||
value={`${urlObj.protocol}//`}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user