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:
Matthieu Sieben 2025-03-07 09:41:06 +01:00 committed by GitHub
parent 442fcce308
commit 850e39843c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
244 changed files with 28732 additions and 4022 deletions

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-provider": patch
---
Add support for password reset

View File

@ -0,0 +1,5 @@
---
"@atproto-labs/fetch": patch
---
Improved error response parsing

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-provider": minor
---
Add support for account sign-up

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-client-browser-example": patch
---
Update react to version 19

View File

@ -0,0 +1,7 @@
---
"@atproto/oauth-provider": patch
"@atproto/oauth-types": patch
"@atproto/jwk": patch
---
Properly support locales with 3 chars (Asturian)

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-provider": patch
---
Add support for multiple locales

View File

@ -0,0 +1,5 @@
---
"@atproto/syntax": patch
---
Deprecate unused classes

View File

@ -0,0 +1,5 @@
---
"@atproto-labs/rollup-plugin-bundle-manifest": patch
---
Improve typing of plugin

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-client": patch
---
Minor code optimizations

View File

@ -0,0 +1,5 @@
---
"@atproto-labs/fetch": patch
---
Remove explicit dependency on "zod". Improved typing of `fetchJsonZodProcessor` function.

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-client-browser-example": patch
---
Build using SWC

View File

@ -0,0 +1,5 @@
---
"@atproto/pds": patch
---
Add support for account sign-ups during OAuth flows

View File

@ -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

1
.npmrc
View File

@ -1 +1,2 @@
enable-pre-post-scripts = true
include-workspace-root = true

View File

@ -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

View File

@ -12,6 +12,7 @@
"consolas",
"dpop",
"googleusercontent",
"hcaptcha",
"hexeditor",
"ingester",
"insertable",

View File

@ -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 ."

View File

@ -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',

View File

@ -28,9 +28,6 @@
"devDependencies": {
"typescript": "^5.6.3"
},
"optionalDependencies": {
"zod": "^3.23.8"
},
"scripts": {
"build": "tsc --build tsconfig.json"
}

View File

@ -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

View File

@ -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<

View File

@ -32,7 +32,7 @@ export default function bundleManifest({
}: {
name?: string
data?: boolean
} = {}): Plugin {
} = {}): Plugin<never> {
return {
name: 'bundle-manifest',
generateBundle(outputOptions, bundle) {

View File

@ -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(),

View File

@ -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",

View File

@ -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 &&

View File

@ -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',
})

View File

@ -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>

View File

@ -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

View File

@ -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>
)

View File

@ -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

View File

@ -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')

View File

@ -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>,
)

View File

@ -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'],
}

View File

@ -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}` : ''

View File

@ -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
*/

View File

@ -0,0 +1,2 @@
src/assets/app/locales/*/*.ts
.swc

View 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/**"
]
}
]
}

View File

@ -1,3 +0,0 @@
plugins:
tailwindcss: {}
autoprefixer: {}

View File

@ -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": {}
}
}
}

View File

@ -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

View File

@ -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)
})
}
}

View File

@ -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')
}

View 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>

View 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>

View File

@ -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>
)
}

View File

@ -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')

View File

@ -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[]
}

View File

@ -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
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
}

View File

@ -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>
),
)

View File

@ -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>
)
},
)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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}
/>
)
}

View File

@ -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}
/>
</>
}
/>
)
}

View File

@ -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}
</>
}
/>
)
}

View File

@ -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>
)
}

View File

@ -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)}`
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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',
)

View File

@ -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',
)

View File

@ -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',
)

View File

@ -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',
)

View File

@ -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',
)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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}
/>
)
},
)

View File

@ -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>
)
},
)

View File

@ -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>
)
})

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -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'
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
})

View File

@ -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>
}

View File

@ -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>
)
})

View File

@ -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>
})

View File

@ -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>
)
}

View File

@ -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',
)

View File

@ -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>
)
}

View File

@ -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
}
/>
)
}

View File

@ -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
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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