From 79ca2a99398d873c7ce7b4437d370fe63e2d17e5 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 20 Apr 2025 10:46:29 +0200 Subject: [PATCH] add bcrypt and refactored auth resolve (#147) * reworked auth architecture with improved password handling and claims Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly. * fix strategy forms handling, add register route and hidden fields Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components. * refactored auth handling to support bcrypt, extracted user pool * update email regex to allow '+' and '_' characters * update test stub password for AppAuth spec * update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool * rework strategies to extend a base class instead of interface * added simple bcrypt test --- app/__test__/modules/AppAuth.spec.ts | 61 ++++- app/package.json | 1 + app/src/auth/AppAuth.ts | 142 +---------- app/src/auth/AppUserPool.ts | 83 +++++++ app/src/auth/authenticate/Authenticator.ts | 205 ++++++++++------ .../strategies/PasswordStrategy.ts | 228 ++++++++---------- .../auth/authenticate/strategies/Strategy.ts | 63 +++++ .../strategies/oauth/CustomOAuthStrategy.ts | 53 ++-- .../strategies/oauth/OAuthStrategy.ts | 86 +++---- app/src/auth/errors.ts | 52 +++- app/src/core/errors.ts | 7 +- app/src/core/utils/reqres.ts | 18 +- app/src/core/utils/strings.ts | 5 + app/src/core/utils/typebox/index.ts | 5 + app/src/data/errors.ts | 16 +- app/src/modules/server/AdminController.tsx | 9 +- app/src/modules/server/AppServer.ts | 7 +- app/src/ui/components/display/Message.tsx | 2 + .../form/json-schema-form/Field.tsx | 67 +++-- .../form/json-schema-form/FieldWrapper.tsx | 4 +- .../components/form/json-schema-form/utils.ts | 7 + app/src/ui/elements/auth/AuthForm.tsx | 2 - app/src/ui/elements/auth/AuthScreen.tsx | 8 +- app/src/ui/routes/auth/auth.register.tsx | 18 ++ app/src/ui/routes/auth/auth.strategies.tsx | 33 ++- app/src/ui/routes/index.tsx | 2 + app/vite.dev.ts | 2 +- bun.lock | 12 +- 28 files changed, 721 insertions(+), 477 deletions(-) create mode 100644 app/src/auth/AppUserPool.ts create mode 100644 app/src/auth/authenticate/strategies/Strategy.ts create mode 100644 app/src/ui/routes/auth/auth.register.tsx diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 9091fbba..ede12f6a 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -69,7 +69,7 @@ describe("AppAuth", () => { }, body: JSON.stringify({ email: "some@body.com", - password: "123456", + password: "12345678", }), }); enableConsoleLog(); @@ -81,6 +81,65 @@ describe("AppAuth", () => { } }); + test("creates user on register (bcrypt)", async () => { + const auth = new AppAuth( + { + enabled: true, + strategies: { + password: { + type: "password", + config: { + hashing: "bcrypt", + }, + }, + }, + // @ts-ignore + jwt: { + secret: "123456", + }, + }, + ctx, + ); + + await auth.build(); + await ctx.em.schema().sync({ force: true }); + + // expect no users, but the query to pass + const res = await ctx.em.repository("users").findMany(); + expect(res.data.length).toBe(0); + + const app = new AuthController(auth).getController(); + + { + disableConsoleLog(); + const res = await app.request("/password/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "some@body.com", + password: "12345678", + }), + }); + enableConsoleLog(); + expect(res.status).toBe(200); + + const { data: users } = await ctx.em.repository("users").findMany(); + expect(users.length).toBe(1); + expect(users[0]?.email).toBe("some@body.com"); + } + + { + // check user in database + const rawUser = await ctx.connection.kysely + .selectFrom("users") + .selectAll() + .executeTakeFirstOrThrow(); + expect(rawUser.strategy_value).toStartWith("$"); + } + }); + test("registers auth middleware for bknd routes only", async () => { const app = createApp({ initialConfig: { diff --git a/app/package.json b/app/package.json index 5fc96043..96b1d44d 100644 --- a/app/package.json +++ b/app/package.json @@ -58,6 +58,7 @@ "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", "aws4fetch": "^1.0.20", + "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", "hono": "^4.7.4", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 17ca1fc0..e42ac05b 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,20 +1,13 @@ -import { - type AuthAction, - AuthPermissions, - Authenticator, - type ProfileExchange, - Role, - type Strategy, -} from "auth"; +import { Authenticator, AuthPermissions, Role, type Strategy } from "auth"; import type { PasswordStrategy } from "auth/authenticate/strategies"; -import { $console, type DB, Exception, type PrimaryFieldType } from "core"; -import { type Static, secureRandomString, transformObject } from "core/utils"; +import { $console, type DB, type PrimaryFieldType } from "core"; +import { secureRandomString, transformObject } from "core/utils"; import type { Entity, EntityManager } from "data"; -import { type FieldSchema, em, entity, enumm, text } from "data/prototype"; -import { pick } from "lodash-es"; +import { em, entity, enumm, type FieldSchema, text } from "data/prototype"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; -import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; +import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema"; +import { AppUserPool } from "auth/AppUserPool"; export type UserFieldSchema = FieldSchema; declare module "core" { @@ -23,7 +16,6 @@ declare module "core" { } } -type AuthSchema = Static; export type CreateUserPayload = { email: string; password: string; [key: string]: any }; export class AppAuth extends Module { @@ -31,7 +23,7 @@ export class AppAuth extends Module { cache: Record = {}; _controller!: AuthController; - override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { + override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) { const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; if (!from.enabled && to.enabled) { @@ -80,7 +72,7 @@ export class AppAuth extends Module { } }); - this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { + this._authenticator = new Authenticator(strategies, new AppUserPool(this), { jwt: this.config.jwt, cookie: this.config.cookie, }); @@ -122,119 +114,6 @@ export class AppAuth extends Module { return this.ctx.em as any; } - private async resolveUser( - action: AuthAction, - strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - if (!this.config.allow_register && action === "register") { - throw new Exception("Registration is not allowed", 403); - } - - const fields = this.getUsersEntity() - .getFillableFields("create") - .map((f) => f.name); - const filteredProfile = Object.fromEntries( - Object.entries(profile).filter(([key]) => fields.includes(key)), - ); - - switch (action) { - case "login": - return this.login(strategy, identifier, filteredProfile); - case "register": - return this.register(strategy, identifier, filteredProfile); - } - } - - private filterUserData(user: any) { - return pick(user, this.config.jwt.fields); - } - - private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) { - if (!("email" in profile)) { - throw new Exception("Profile must have email"); - } - if (typeof identifier !== "string" || identifier.length === 0) { - throw new Exception("Identifier must be a string"); - } - - const users = this.getUsersEntity(); - this.toggleStrategyValueVisibility(true); - const result = await this.em - .repo(users as unknown as "users") - .findOne({ email: profile.email! }); - this.toggleStrategyValueVisibility(false); - if (!result.data) { - throw new Exception("User not found", 404); - } - - // compare strategy and identifier - if (result.data.strategy !== strategy.getName()) { - throw new Exception("User registered with different strategy"); - } - - if (result.data.strategy_value !== identifier) { - throw new Exception("Invalid credentials"); - } - - return this.filterUserData(result.data); - } - - private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) { - if (!("email" in profile)) { - throw new Exception("Profile must have an email"); - } - if (typeof identifier !== "string" || identifier.length === 0) { - throw new Exception("Identifier must be a string"); - } - - const users = this.getUsersEntity(); - const { data } = await this.em.repo(users).findOne({ email: profile.email! }); - if (data) { - throw new Exception("User already exists"); - } - - const payload: any = { - ...profile, - strategy: strategy.getName(), - strategy_value: identifier, - }; - - const mutator = this.em.mutator(users); - mutator.__unstable_toggleSystemEntityCreation(false); - this.toggleStrategyValueVisibility(true); - const createResult = await mutator.insertOne(payload); - mutator.__unstable_toggleSystemEntityCreation(true); - this.toggleStrategyValueVisibility(false); - if (!createResult.data) { - throw new Error("Could not create user"); - } - - return this.filterUserData(createResult.data); - } - - private toggleStrategyValueVisibility(visible: boolean) { - const toggle = (name: string, visible: boolean) => { - const field = this.getUsersEntity().field(name)!; - - if (visible) { - field.config.hidden = false; - field.config.fillable = true; - } else { - // reset to normal - const template = AppAuth.usersFields.strategy_value.config; - field.config.hidden = template.hidden; - field.config.fillable = template.fillable; - } - }; - - toggle("strategy_value", visible); - toggle("strategy", visible); - - // @todo: think about a PasswordField that automatically hashes on save? - } - getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> { const entity_name = this.config.entity_name; if (forceCreate || !this.em.hasEntity(entity_name)) { @@ -287,7 +166,7 @@ export class AppAuth extends Module { throw new Error("Cannot create user, auth not enabled"); } - const strategy = "password"; + const strategy = "password" as const; const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const strategy_value = await pw.hash(password); const mutator = this.em.mutator(this.config.entity_name as "users"); @@ -314,8 +193,7 @@ export class AppAuth extends Module { ...this.authenticator.toJSON(secrets), strategies: transformObject(strategies, (strategy) => ({ enabled: this.isStrategyEnabled(strategy), - type: strategy.getType(), - config: strategy.toJSON(secrets), + ...strategy.toJSON(secrets), })), }; } diff --git a/app/src/auth/AppUserPool.ts b/app/src/auth/AppUserPool.ts new file mode 100644 index 00000000..23f24d06 --- /dev/null +++ b/app/src/auth/AppUserPool.ts @@ -0,0 +1,83 @@ +import { AppAuth } from "auth/AppAuth"; +import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator"; +import { $console } from "core"; +import { pick } from "lodash-es"; +import { + InvalidConditionsException, + UnableToCreateUserException, + UserNotFoundException, +} from "auth/errors"; + +export class AppUserPool implements UserPool { + constructor(private appAuth: AppAuth) {} + + get em() { + return this.appAuth.em; + } + + get users() { + return this.appAuth.getUsersEntity(); + } + + async findBy(strategy: string, prop: keyof SafeUser, value: any) { + $console.debug("[AppUserPool:findBy]", { strategy, prop, value }); + this.toggleStrategyValueVisibility(true); + const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy }); + this.toggleStrategyValueVisibility(false); + + if (!result.data) { + $console.debug("[AppUserPool]: User not found"); + throw new UserNotFoundException(); + } + + return result.data; + } + + async create(strategy: string, payload: CreateUser & Partial>) { + $console.debug("[AppUserPool:create]", { strategy, payload }); + if (!("strategy_value" in payload)) { + throw new InvalidConditionsException("Profile must have a strategy_value value"); + } + + const fields = this.users.getSelect(undefined, "create"); + const safeProfile = pick(payload, fields) as any; + const createPayload: Omit = { + ...safeProfile, + strategy, + }; + + const mutator = this.em.mutator(this.users); + mutator.__unstable_toggleSystemEntityCreation(false); + this.toggleStrategyValueVisibility(true); + const createResult = await mutator.insertOne(createPayload); + mutator.__unstable_toggleSystemEntityCreation(true); + this.toggleStrategyValueVisibility(false); + if (!createResult.data) { + throw new UnableToCreateUserException(); + } + + $console.debug("[AppUserPool]: User created", createResult.data); + return createResult.data; + } + + private toggleStrategyValueVisibility(visible: boolean) { + const toggle = (name: string, visible: boolean) => { + const field = this.users.field(name)!; + + if (visible) { + field.config.hidden = false; + field.config.fillable = true; + } else { + // reset to normal + const template = AppAuth.usersFields.strategy_value.config; + field.config.hidden = template.hidden; + field.config.fillable = template.fillable; + } + }; + + toggle("strategy_value", visible); + toggle("strategy", visible); + + // @todo: think about a PasswordField that automatically hashes on save? + } +} diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index c4f1100c..6632a6f5 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,4 +1,4 @@ -import { $console, type DB, Exception, type PrimaryFieldType } from "core"; +import { $console, type DB, Exception } from "core"; import { addFlashMessage } from "core/server/flash"; import { type Static, @@ -6,6 +6,7 @@ import { type TObject, parse, runtimeSupports, + truncate, } from "core/utils"; import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; @@ -14,6 +15,7 @@ import type { CookieOptions } from "hono/utils/cookie"; import type { ServerEnv } from "modules/Controller"; import { pick } from "lodash-es"; import * as tbbox from "@sinclair/typebox"; +import { InvalidConditionsException } from "auth/errors"; const { Type } = tbbox; type Input = any; // workaround @@ -23,11 +25,12 @@ export const strategyActions = ["create", "change"] as const; export type StrategyActionName = (typeof strategyActions)[number]; export type StrategyAction = { schema: S; - preprocess: (input: unknown) => Promise>; + preprocess: (input: Static) => Promise>; }; export type StrategyActions = Partial>; // @todo: add schema to interface to ensure proper inference +// @todo: add tests (e.g. invalid strategy_value) export interface Strategy { getController: (auth: Authenticator) => Hono; getType: () => string; @@ -37,28 +40,22 @@ export interface Strategy { getActions?: () => StrategyActions; } -export type User = { - id: PrimaryFieldType; - email: string; - password: string; - role?: string | null; -}; +export type User = DB["users"]; export type ProfileExchange = { email?: string; - username?: string; - sub?: string; - password?: string; + strategy?: string; + strategy_value?: string; [key: string]: any; }; -export type SafeUser = Omit; +export type SafeUser = Omit; export type CreateUser = Pick & { [key: string]: any }; export type AuthResponse = { user: SafeUser; token: string }; -export interface UserPool { - findBy: (prop: Fields, value: string | number) => Promise; - create: (user: CreateUser) => Promise; +export interface UserPool { + findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise; + create: (strategy: string, user: CreateUser) => Promise; } const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds @@ -100,12 +97,17 @@ export const authenticatorConfig = Type.Object({ type AuthConfig = Static; export type AuthAction = "login" | "register"; +export type AuthResolveOptions = { + identifier?: "email" | string; + redirect?: string; + forceJsonResponse?: boolean; +}; export type AuthUserResolver = ( action: AuthAction, strategy: Strategy, - identifier: string, profile: ProfileExchange, -) => Promise; + opts?: AuthResolveOptions, +) => Promise; type AuthClaims = SafeUser & { iat: number; iss?: string; @@ -113,32 +115,117 @@ type AuthClaims = SafeUser & { }; export class Authenticator = Record> { - private readonly strategies: Strategies; private readonly config: AuthConfig; - private readonly userResolver: AuthUserResolver; - constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { - this.userResolver = userResolver ?? (async (a, s, i, p) => p as any); - this.strategies = strategies as Strategies; + constructor( + private readonly strategies: Strategies, + private readonly userPool: UserPool, + config?: AuthConfig, + ) { this.config = parse(authenticatorConfig, config ?? {}); } - async resolve( - action: AuthAction, + async resolveLogin( + c: Context, strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - const user = await this.userResolver(action, strategy, identifier, profile); + profile: Partial, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + // @todo: centralize identifier and checks + // @todo: check identifier value (if allowed) + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } - if (user) { - return { - user, - token: await this.jwt(user), - }; + const user = await this.userPool.findBy( + strategy.getName(), + identifier as any, + profile[identifier], + ); + + if (!user.strategy_value) { + throw new InvalidConditionsException("User must have a strategy value"); + } else if (user.strategy !== strategy.getName()) { + throw new InvalidConditionsException("User signed up with a different strategy"); + } + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + async resolveRegister( + c: Context, + strategy: Strategy, + profile: CreateUser, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } + if (!("strategy_value" in profile)) { + throw new InvalidConditionsException("Profile must have a strategy value"); + } + + const user = await this.userPool.create(strategy.getName(), { + ...profile, + strategy_value: profile.strategy_value, + }); + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + private async respondWithUser(c: Context, data: AuthResponse, opts?: AuthResolveOptions) { + const successUrl = this.getSafeUrl( + c, + opts?.redirect ?? this.config.cookie.pathSuccess ?? "/", + ); + + if ("token" in data) { + await this.setAuthCookie(c, data.token); + + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + return c.json(data); + } + + // can't navigate to "/" – doesn't work on nextjs + return c.redirect(successUrl); } - throw new Error("User could not be resolved"); + throw new Exception("Invalid response"); + } + + private async respondWithError(c: Context, error: Error, opts?: AuthResolveOptions) { + $console.error("respondWithError", error); + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + // let the server handle it + throw error; + } + + await addFlashMessage(c, String(error), "error"); + + const referer = opts?.redirect ?? c.req.header("Referer") ?? "/"; + return c.redirect(referer); } getStrategies(): Strategies { @@ -157,7 +244,7 @@ export class Authenticator = Record< } // @todo: add jwt tests - async jwt(_user: Omit): Promise { + async jwt(_user: SafeUser | ProfileExchange): Promise { const user = pick(_user, this.config.jwt.fields); const payload: JWTPayload = { @@ -183,6 +270,14 @@ export class Authenticator = Record< return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); } + async safeAuthResponse(_user: User): Promise { + const user = pick(_user, this.config.jwt.fields) as SafeUser; + return { + user, + token: await this.jwt(user), + }; + } + async verify(jwt: string): Promise { try { const payload = await verify( @@ -241,11 +336,13 @@ export class Authenticator = Record< } private async setAuthCookie(c: Context, token: string) { + $console.debug("setting auth cookie", truncate(token)); const secret = this.config.jwt.secret; await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } private async deleteAuthCookie(c: Context) { + $console.debug("deleting auth cookie"); await deleteCookie(c, "auth", this.cookieOptions); } @@ -284,34 +381,6 @@ export class Authenticator = Record< return p; } - async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { - const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/"); - const referer = redirect ?? c.req.header("Referer") ?? successUrl; - - if ("token" in data) { - await this.setAuthCookie(c, data.token); - - if (this.isJsonRequest(c)) { - return c.json(data); - } - - // can't navigate to "/" – doesn't work on nextjs - return c.redirect(successUrl); - } - - if (this.isJsonRequest(c)) { - return c.json(data, 400); - } - - let message = "An error occured"; - if (data instanceof Exception) { - message = data.message; - } - - await addFlashMessage(c, message, "error"); - return c.redirect(referer); - } - // @todo: don't extract user from token, but from the database or cache async resolveAuthFromRequest(c: Context): Promise { let token: string | undefined; @@ -336,13 +405,3 @@ export class Authenticator = Record< }; } } - -export function createStrategyAction( - schema: S, - preprocess: (input: Static) => Promise>, -) { - return { - schema, - preprocess, - } as StrategyAction; -} diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 7166d26b..3031d636 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,151 +1,123 @@ -import type { Authenticator, Strategy } from "auth"; -import { tbValidator as tb } from "core"; -import { type Static, StringEnum, parse } from "core/utils"; -import { hash } from "core/utils"; +import { type Authenticator, InvalidCredentialsException, type User } from "auth"; +import { $console, tbValidator as tb } from "core"; +import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils"; import { Hono } from "hono"; -import { type StrategyActions, createStrategyAction } from "../Authenticator"; +import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "./Strategy"; + const { Type } = tbbox; -type LoginSchema = { username: string; password: string } | { email: string; password: string }; -type RegisterSchema = { email: string; password: string; [key: string]: any }; - -const schema = Type.Object({ - hashing: StringEnum(["plain", "sha256"] as const, { default: "sha256" }), +const schema = StrictObject({ + hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }), + rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })), }); export type PasswordStrategyOptions = Static; -export class PasswordStrategy implements Strategy { - private options: PasswordStrategyOptions; +export class PasswordStrategy extends Strategy { + constructor(config: Partial = {}) { + super(config as any, "password", "password", "form"); - constructor(options: Partial = {}) { - this.options = parse(schema, options); - } - - async hash(password: string) { - switch (this.options.hashing) { - case "sha256": - return hash.sha256(password); - default: - return password; - } - } - - async login(input: LoginSchema) { - if (!("email" in input) || !("password" in input)) { - throw new Error("Invalid input: Email and password must be provided"); - } - - const hashedPassword = await this.hash(input.password); - return { ...input, password: hashedPassword }; - } - - async register(input: RegisterSchema) { - if (!input.email || !input.password) { - throw new Error("Invalid input: Email and password must be provided"); - } - - return { - ...input, - password: await this.hash(input.password), - }; - } - - getController(authenticator: Authenticator): Hono { - const hono = new Hono(); - - return hono - .post( - "/login", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - try { - const payload = await this.login(body); - const data = await authenticator.resolve( - "login", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - } catch (e) { - return await authenticator.respond(c, e); - } - }, - ) - .post( - "/register", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - const payload = await this.register(body); - const data = await authenticator.resolve( - "register", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - }, - ); - } - - getActions(): StrategyActions { - return { - create: createStrategyAction( - Type.Object({ - email: Type.String({ - pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", - }), - password: Type.String({ - minLength: 8, // @todo: this should be configurable - }), - }), - async ({ password, ...input }) => { - return { - ...input, - strategy_value: await this.hash(password), - }; - }, - ), - }; + this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => { + return { + ...input, + strategy_value: await this.hash(password), + }; + }); } getSchema() { return schema; } - getType() { - return "password"; + private getPayloadSchema() { + return Type.Object({ + email: Type.String({ + pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$", + }), + password: Type.String({ + minLength: 8, // @todo: this should be configurable + }), + }); } - getMode() { - return "form" as const; + async hash(password: string) { + switch (this.config.hashing) { + case "sha256": + return hash.sha256(password); + case "bcrypt": { + const salt = await bcryptGenSalt(this.config.rounds ?? 4); + return bcryptHash(password, salt); + } + default: + return password; + } } - getName() { - return "password" as const; + async compare(actual: string, compare: string): Promise { + switch (this.config.hashing) { + case "sha256": { + const compareHashed = await this.hash(compare); + return actual === compareHashed; + } + case "bcrypt": + return await bcryptCompare(compare, actual); + } + + return false; } - toJSON(secrets?: boolean) { - return secrets ? this.options : undefined; + verify(password: string) { + return async (user: User) => { + const compare = await this.compare(user?.strategy_value!, password); + if (compare !== true) { + throw new InvalidCredentialsException(); + } + }; + } + + getController(authenticator: Authenticator): Hono { + const hono = new Hono(); + const redirectQuerySchema = Type.Object({ + redirect: Type.Optional(Type.String()), + }); + const payloadSchema = this.getPayloadSchema(); + + hono.post("/login", tb("query", redirectQuerySchema), async (c) => { + const body = parse(payloadSchema, await authenticator.getBody(c), { + onError: (errors) => { + $console.error("Invalid login payload", [...errors]); + throw new InvalidCredentialsException(); + }, + }); + const { redirect } = c.req.valid("query"); + + return await authenticator.resolveLogin(c, this, body, this.verify(body.password), { + redirect, + }); + }); + + hono.post("/register", tb("query", redirectQuerySchema), async (c) => { + const { redirect } = c.req.valid("query"); + const { password, email, ...body } = parse(payloadSchema, await authenticator.getBody(c), { + onError: (errors) => { + $console.error("Invalid register payload", [...errors]); + throw new InvalidCredentialsException(); + }, + }); + + const profile = { + ...body, + email, + strategy_value: await this.hash(password), + }; + + return await authenticator.resolveRegister(c, this, profile, async () => void 0, { + redirect, + }); + }); + + return hono; } } diff --git a/app/src/auth/authenticate/strategies/Strategy.ts b/app/src/auth/authenticate/strategies/Strategy.ts new file mode 100644 index 00000000..28fb95cf --- /dev/null +++ b/app/src/auth/authenticate/strategies/Strategy.ts @@ -0,0 +1,63 @@ +import type { + Authenticator, + StrategyAction, + StrategyActionName, + StrategyActions, +} from "../Authenticator"; +import type { Hono } from "hono"; +import type { Static, TSchema } from "@sinclair/typebox"; +import { parse, type TObject } from "core/utils"; + +export type StrategyMode = "form" | "external"; + +export abstract class Strategy { + protected actions: StrategyActions = {}; + + constructor( + protected config: Static, + public type: string, + public name: string, + public mode: StrategyMode, + ) { + // don't worry about typing, it'll throw if invalid + this.config = parse(this.getSchema(), (config ?? {}) as any) as Static; + } + + protected registerAction( + name: StrategyActionName, + schema: S, + preprocess: StrategyAction["preprocess"], + ): void { + this.actions[name] = { + schema, + preprocess, + } as const; + } + + protected abstract getSchema(): Schema; + + abstract getController(auth: Authenticator): Hono; + + getType(): string { + return this.type; + } + + getMode() { + return this.mode; + } + + getName(): string { + return this.name; + } + + toJSON(secrets?: boolean): { type: string; config: Static | {} | undefined } { + return { + type: this.getType(), + config: secrets ? this.config : undefined, + }; + } + + getActions(): StrategyActions { + return this.actions; + } +} diff --git a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts index 7b193c6d..9e9c3b81 100644 --- a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts @@ -1,4 +1,4 @@ -import { type Static, StringEnum } from "core/utils"; +import { type Static, StrictObject, StringEnum } from "core/utils"; import * as tbbox from "@sinclair/typebox"; import type * as oauth from "oauth4webapi"; import { OAuthStrategy } from "./OAuthStrategy"; @@ -9,37 +9,27 @@ type SupportedTypes = "oauth2" | "oidc"; type RequireKeys = Required> & Omit; const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" }); -const oauthSchemaCustom = Type.Object( +const oauthSchemaCustom = StrictObject( { type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }), name: Type.String(), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - token_endpoint_auth_method: StringEnum(["client_secret_basic"]), - }, - { - additionalProperties: false, - }, - ), - as: Type.Object( - { - issuer: Type.String(), - code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), - scopes_supported: Type.Optional(Type.Array(Type.String())), - scope_separator: Type.Optional(Type.String({ default: " " })), - authorization_endpoint: Type.Optional(UrlString), - token_endpoint: Type.Optional(UrlString), - userinfo_endpoint: Type.Optional(UrlString), - }, - { - additionalProperties: false, - }, - ), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + token_endpoint_auth_method: StringEnum(["client_secret_basic"]), + }), + as: StrictObject({ + issuer: Type.String(), + code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), + scopes_supported: Type.Optional(Type.Array(Type.String())), + scope_separator: Type.Optional(Type.String({ default: " " })), + authorization_endpoint: Type.Optional(UrlString), + token_endpoint: Type.Optional(UrlString), + userinfo_endpoint: Type.Optional(UrlString), + }), // @todo: profile mapping }, - { title: "Custom OAuth", additionalProperties: false }, + { title: "Custom OAuth" }, ); type OAuthConfigCustom = Static; @@ -64,6 +54,11 @@ export type IssuerConfig = { }; export class CustomOAuthStrategy extends OAuthStrategy { + constructor(config: OAuthConfigCustom) { + super(config as any); + this.type = "custom_oauth"; + } + override getIssuerConfig(): IssuerConfig { return { ...this.config, profile: async (info) => info } as any; } @@ -72,8 +67,4 @@ export class CustomOAuthStrategy extends OAuthStrategy { override getSchema() { return oauthSchemaCustom; } - - override getType() { - return "custom_oauth"; - } } diff --git a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts index 481e747a..2055d17c 100644 --- a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts @@ -1,11 +1,12 @@ -import type { AuthAction, Authenticator, Strategy } from "auth"; +import type { AuthAction, Authenticator } from "auth"; import { Exception, isDebug } from "core"; -import { type Static, StringEnum, filterKeys } from "core/utils"; +import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils"; import { type Context, Hono } from "hono"; import { getSignedCookie, setSignedCookie } from "hono/cookie"; import * as oauth from "oauth4webapi"; import * as issuers from "./issuers"; import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "auth/authenticate/strategies/Strategy"; const { Type } = tbbox; type ConfiguredIssuers = keyof typeof issuers; @@ -16,15 +17,11 @@ type RequireKeys = Required> & O const schemaProvided = Type.Object( { name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - }, - { - additionalProperties: false, - }, - ), + type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + }), }, { title: "OAuth" }, ); @@ -72,11 +69,13 @@ export class OAuthCallbackException extends Exception { } } -export class OAuthStrategy implements Strategy { - constructor(private _config: OAuthConfig) {} +export class OAuthStrategy extends Strategy { + constructor(config: ProvidedOAuthConfig) { + super(config, "oauth", config.name, "external"); + } - get config() { - return this._config; + getSchema() { + return schemaProvided; } getIssuerConfig(): IssuerConfig { @@ -104,7 +103,7 @@ export class OAuthStrategy implements Strategy { type: info.type, client: { ...info.client, - ...this._config.client, + ...this.config.client, }, }; } @@ -339,20 +338,28 @@ export class OAuthStrategy implements Strategy { state: state.state, }); - try { - const data = await auth.resolve(state.action, this, profile.sub, profile); + const safeProfile = { + email: profile.email, + strategy_value: profile.sub, + } as const; - if (state.mode === "cookie") { - return await auth.respond(c, data, state.redirect); + const verify = async (user) => { + if (user.strategy_value !== profile.sub) { + throw new Exception("Invalid credentials"); } + }; + const opts = { + redirect: state.redirect, + forceJsonResponse: state.mode !== "cookie", + } as const; - return c.json(data); - } catch (e) { - if (state.mode === "cookie") { - return await auth.respond(c, e, state.redirect); - } - - throw e; + switch (state.action) { + case "login": + return auth.resolveLogin(c, this, safeProfile, verify, opts); + case "register": + return auth.resolveRegister(c, this, safeProfile, verify, opts); + default: + throw new Error("Invalid action"); } }); @@ -423,28 +430,15 @@ export class OAuthStrategy implements Strategy { return hono; } - getType() { - return "oauth"; - } - - getMode() { - return "external" as const; - } - - getName() { - return this.config.name; - } - - getSchema() { - return schemaProvided; - } - - toJSON(secrets?: boolean) { + override toJSON(secrets?: boolean) { const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]); return { - type: this.getIssuerConfig().type, - ...config, + ...super.toJSON(secrets), + config: { + ...config, + type: this.getIssuerConfig().type, + }, }; } } diff --git a/app/src/auth/errors.ts b/app/src/auth/errors.ts index e10266eb..7b725af8 100644 --- a/app/src/auth/errors.ts +++ b/app/src/auth/errors.ts @@ -1,28 +1,66 @@ -import { Exception } from "core"; +import { Exception, isDebug } from "core"; +import { HttpStatus } from "core/utils"; -export class UserExistsException extends Exception { +export class AuthException extends Exception { + getSafeErrorAndCode() { + return { + error: "Invalid credentials", + code: HttpStatus.UNAUTHORIZED, + }; + } + + override toJSON(): any { + if (isDebug()) { + return super.toJSON(); + } + + return { + error: this.getSafeErrorAndCode().error, + type: "AuthException", + }; + } +} + +export class UserExistsException extends AuthException { override name = "UserExistsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; constructor() { super("User already exists"); } } -export class UserNotFoundException extends Exception { +export class UserNotFoundException extends AuthException { override name = "UserNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor() { super("User not found"); } } -export class InvalidCredentialsException extends Exception { +export class InvalidCredentialsException extends AuthException { override name = "InvalidCredentialsException"; - override code = 401; + override code = HttpStatus.UNAUTHORIZED; constructor() { super("Invalid credentials"); } } + +export class UnableToCreateUserException extends AuthException { + override name = "UnableToCreateUserException"; + override code = HttpStatus.INTERNAL_SERVER_ERROR; + + constructor() { + super("Unable to create user"); + } +} + +export class InvalidConditionsException extends AuthException { + override code = HttpStatus.UNPROCESSABLE_ENTITY; + + constructor(message: string) { + super(message ?? "Invalid conditions"); + } +} diff --git a/app/src/core/errors.ts b/app/src/core/errors.ts index 9173b4bf..4b64c346 100644 --- a/app/src/core/errors.ts +++ b/app/src/core/errors.ts @@ -1,9 +1,12 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { HttpStatus } from "./utils/reqres"; + export class Exception extends Error { - code = 400; + code: ContentfulStatusCode = HttpStatus.BAD_REQUEST; override name = "Exception"; protected _context = undefined; - constructor(message: string, code?: number) { + constructor(message: string, code?: ContentfulStatusCode) { super(message); if (code) { this.code = code; diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts index 7f6c0844..0ba600fc 100644 --- a/app/src/core/utils/reqres.ts +++ b/app/src/core/utils/reqres.ts @@ -98,7 +98,7 @@ export function decodeSearch(str) { export const enum HttpStatus { // Informational responses (100–199) CONTINUE = 100, - SWITCHING_PROTOCOLS = 101, + //SWITCHING_PROTOCOLS = 101, PROCESSING = 102, EARLY_HINTS = 103, @@ -107,8 +107,8 @@ export const enum HttpStatus { CREATED = 201, ACCEPTED = 202, NON_AUTHORITATIVE_INFORMATION = 203, - NO_CONTENT = 204, - RESET_CONTENT = 205, + //NO_CONTENT = 204, + //RESET_CONTENT = 205, PARTIAL_CONTENT = 206, MULTI_STATUS = 207, ALREADY_REPORTED = 208, @@ -119,7 +119,7 @@ export const enum HttpStatus { MOVED_PERMANENTLY = 301, FOUND = 302, SEE_OTHER = 303, - NOT_MODIFIED = 304, + //NOT_MODIFIED = 304, USE_PROXY = 305, TEMPORARY_REDIRECT = 307, PERMANENT_REDIRECT = 308, @@ -168,3 +168,13 @@ export const enum HttpStatus { NOT_EXTENDED = 510, NETWORK_AUTHENTICATION_REQUIRED = 511, } +// biome-ignore lint/suspicious/noConstEnum: +export const enum HttpStatusEmpty { + // Informational responses (100–199) + SWITCHING_PROTOCOLS = 101, + // Successful responses (200–299) + NO_CONTENT = 204, + RESET_CONTENT = 205, + // Redirection messages (300–399) + NOT_MODIFIED = 304, +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index be115f52..c0523312 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -132,3 +132,8 @@ export function slugify(str: string): string { .replace(/-+/g, "-") // remove consecutive hyphens ); } + +export function truncate(str: string, length = 50, end = "..."): string { + if (str.length <= length) return str; + return str.substring(0, length) + end; +} diff --git a/app/src/core/utils/typebox/index.ts b/app/src/core/utils/typebox/index.ts index 036e3f7b..1267afc7 100644 --- a/app/src/core/utils/typebox/index.ts +++ b/app/src/core/utils/typebox/index.ts @@ -179,6 +179,11 @@ export const StringIdentifier = tb.Type.String({ maxLength: 150, }); +export const StrictObject = ( + properties: T, + options?: tb.ObjectOptions, +): tb.TObject => tb.Type.Object(properties, { ...options, additionalProperties: false }); + SetErrorFunction((error) => { if (error?.schema?.errorMessage) { return error.schema.errorMessage; diff --git a/app/src/data/errors.ts b/app/src/data/errors.ts index 9f159f7c..12912a0b 100644 --- a/app/src/data/errors.ts +++ b/app/src/data/errors.ts @@ -1,26 +1,26 @@ import { Exception } from "core"; -import type { TypeInvalidError } from "core/utils"; +import { HttpStatus, type TypeInvalidError } from "core/utils"; import type { Entity } from "./entities"; import type { Field } from "./fields"; export class UnableToConnectException extends Exception { override name = "UnableToConnectException"; - override code = 500; + override code = HttpStatus.INTERNAL_SERVER_ERROR; } export class InvalidSearchParamsException extends Exception { override name = "InvalidSearchParamsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformRetrieveFailedException extends Exception { override name = "TransformRetrieveFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformPersistFailedException extends Exception { override name = "TransformPersistFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; static invalidType(property: string, expected: string, given: any) { const givenValue = typeof given === "object" ? JSON.stringify(given) : given; @@ -37,7 +37,7 @@ export class TransformPersistFailedException extends Exception { export class InvalidFieldConfigException extends Exception { override name = "InvalidFieldConfigException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor( field: Field, @@ -54,7 +54,7 @@ export class InvalidFieldConfigException extends Exception { export class EntityNotDefinedException extends Exception { override name = "EntityNotDefinedException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor(entity?: Entity | string) { if (!entity) { @@ -67,7 +67,7 @@ export class EntityNotDefinedException extends Exception { export class EntityNotFoundException extends Exception { override name = "EntityNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor(entity: Entity | string, id: any) { super( diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index f06c3aa2..1fff45ae 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -72,6 +72,7 @@ export class AdminController extends Controller { success: configs.auth.cookie.pathSuccess ?? this.withAdminBasePath("/"), loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"), login: this.withAdminBasePath("/auth/login"), + register: this.withAdminBasePath("/auth/register"), logout: this.withAdminBasePath("/auth/logout"), }; @@ -92,8 +93,7 @@ export class AdminController extends Controller { }); if (auth_enabled) { - hono.get( - authRoutes.login, + const redirectRouteParams = [ permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], { // @ts-ignore onGranted: async (c) => { @@ -107,7 +107,10 @@ export class AdminController extends Controller { async (c) => { return c.html(c.get("html")!); }, - ); + ] as const; + + hono.get(authRoutes.login, ...redirectRouteParams); + hono.get(authRoutes.register, ...redirectRouteParams); hono.get(authRoutes.logout, async (c) => { await auth.authenticator?.logout(c); diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 9f9d1378..ba1c5662 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -3,6 +3,7 @@ import { type Static, StringEnum } from "core/utils"; import { cors } from "hono/cors"; import { Module } from "modules/Module"; import * as tbbox from "@sinclair/typebox"; +import { AuthException } from "auth/errors"; const { Type } = tbbox; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; @@ -70,12 +71,16 @@ export class AppServer extends Module { this.client.onError((err, c) => { //throw err; - $console.error(err); + $console.error("[AppServer:onError]", err); if (err instanceof Response) { return err; } + if (err instanceof AuthException) { + return c.json(err.toJSON(), err.getSafeErrorAndCode().code); + } + if (err instanceof Exception) { return c.json(err.toJSON(), err.code as any); } diff --git a/app/src/ui/components/display/Message.tsx b/app/src/ui/components/display/Message.tsx index 86790861..ea16f097 100644 --- a/app/src/ui/components/display/Message.tsx +++ b/app/src/ui/components/display/Message.tsx @@ -16,9 +16,11 @@ const MissingPermission = ({ {...props} /> ); +const NotEnabled = (props: Partial) => ; export const Message = { NotFound, NotAllowed, + NotEnabled, MissingPermission, }; diff --git a/app/src/ui/components/form/json-schema-form/Field.tsx b/app/src/ui/components/form/json-schema-form/Field.tsx index e5119772..86010cd6 100644 --- a/app/src/ui/components/form/json-schema-form/Field.tsx +++ b/app/src/ui/components/form/json-schema-form/Field.tsx @@ -1,5 +1,12 @@ import type { JsonSchema } from "json-schema-library"; -import type { ChangeEvent, ComponentPropsWithoutRef, ReactNode } from "react"; +import { + type ChangeEvent, + type ComponentPropsWithoutRef, + type ElementType, + type ReactNode, + useEffect, + useId, +} from "react"; import ErrorBoundary from "ui/components/display/ErrorBoundary"; import * as Formy from "ui/components/form/Formy"; import { useEvent } from "ui/hooks/use-event"; @@ -7,7 +14,7 @@ import { ArrayField } from "./ArrayField"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper"; import { useDerivedFieldContext, useFormValue } from "./Form"; import { ObjectField } from "./ObjectField"; -import { coerce, isType, isTypeSchema } from "./utils"; +import { coerce, firstDefined, isType, isTypeSchema } from "./utils"; export type FieldProps = { onChange?: (e: ChangeEvent) => void; @@ -24,6 +31,19 @@ export const Field = (props: FieldProps) => { ); }; +export const HiddenField = ({ + as = "div", + className, + ...props +}: FieldProps & { as?: ElementType; className?: string }) => { + const Component = as; + return ( + + + + ); +}; + const fieldErrorBoundary = ({ name }: FieldProps) => ({ error }: { error: Error }) => ( @@ -41,8 +61,9 @@ const FieldImpl = ({ ...props }: FieldProps) => { const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name); + const id = `${name}-${useId()}`; const required = typeof _required === "boolean" ? _required : ctx.required; - //console.log("Field", { name, path, schema }); + if (!isTypeSchema(schema)) return (
@@ -58,7 +79,21 @@ const FieldImpl = ({
       return ;
    }
 
-   const disabled = props.disabled ?? schema.readOnly ?? "const" in schema ?? false;
+   // account for `defaultValue`
+   // like 
+   useEffect(() => {
+      if (inputProps?.defaultValue) {
+         setValue(path, inputProps.defaultValue);
+      }
+   }, [inputProps?.defaultValue]);
+
+   const disabled = firstDefined(
+      inputProps?.disabled,
+      props.disabled,
+      schema.readOnly,
+      "const" in schema,
+      false,
+   );
 
    const handleChange = useEvent((e: ChangeEvent) => {
       const value = coerce(e.target.value, schema as any, { required });
@@ -70,9 +105,10 @@ const FieldImpl = ({
    });
 
    return (
-      
+      
          ;
+      return ;
    }
 
    if (isType(schema.type, ["number", "integer"])) {
@@ -121,26 +157,17 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
          step: schema.multipleOf,
       };
 
-      return (
-         
-      );
+      return ;
    }
 
    if (isType(schema.type, "boolean")) {
-      return ;
+      return ;
    }
 
    if (isType(schema.type, "string") && schema.format === "date-time") {
       const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
       return (
          ;
+      return ;
    }
 
    const additional = {
@@ -171,7 +198,5 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
       }
    }
 
-   return (
-      
-   );
+   return ;
 };
diff --git a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
index 2af4e53f..aec5b88c 100644
--- a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
+++ b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
@@ -24,6 +24,7 @@ export type FieldwrapperProps = {
    errorPlacement?: "top" | "bottom";
    description?: string;
    descriptionPlacement?: "top" | "bottom";
+   fieldId?: string;
 };
 
 export function FieldWrapper({
@@ -36,6 +37,7 @@ export function FieldWrapper({
    errorPlacement = "bottom",
    descriptionPlacement = "bottom",
    children,
+   fieldId,
    ...props
 }: FieldwrapperProps) {
    const errors = useFormError(name, { strict: true });
@@ -66,7 +68,7 @@ export function FieldWrapper({
          {label && (
             
                {label} {required && *}
diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts
index db609b55..00f8caf3 100644
--- a/app/src/ui/components/form/json-schema-form/utils.ts
+++ b/app/src/ui/components/form/json-schema-form/utils.ts
@@ -138,3 +138,10 @@ export function omitSchema(_schema: Given, keys: strin
 export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
    return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
 }
+
+export function firstDefined(...args: T[]): T | undefined {
+   for (const arg of args) {
+      if (typeof arg !== "undefined") return arg;
+   }
+   return undefined;
+}
diff --git a/app/src/ui/elements/auth/AuthForm.tsx b/app/src/ui/elements/auth/AuthForm.tsx
index 53232297..aeae50b0 100644
--- a/app/src/ui/elements/auth/AuthForm.tsx
+++ b/app/src/ui/elements/auth/AuthForm.tsx
@@ -9,7 +9,6 @@ import { SocialLink } from "./SocialLink";
 import type { ValueError } from "@sinclair/typebox/value";
 import { type TSchema, Value } from "core/utils";
 import type { Validator } from "json-schema-form-react";
-import { useTheme } from "ui/client/use-theme";
 import * as tbbox from "@sinclair/typebox";
 const { Type } = tbbox;
 
@@ -47,7 +46,6 @@ export function AuthForm({
    buttonLabel = action === "login" ? "Sign in" : "Sign up",
    ...props
 }: LoginFormProps) {
-   const { theme } = useTheme();
    const basepath = auth?.basepath ?? "/api/auth";
    const password = {
       action: `${basepath}/password/${action}`,
diff --git a/app/src/ui/elements/auth/AuthScreen.tsx b/app/src/ui/elements/auth/AuthScreen.tsx
index d31f03dd..b0d7ae01 100644
--- a/app/src/ui/elements/auth/AuthScreen.tsx
+++ b/app/src/ui/elements/auth/AuthScreen.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import { isValidElement, type ReactNode } from "react";
 import { useAuthStrategies } from "../hooks/use-auth";
 import { AuthForm } from "./AuthForm";
 
@@ -30,11 +30,13 @@ export function AuthScreen({
          {!loading && (
             
{logo ? logo : null} - {typeof intro !== "undefined" ? ( + {isValidElement(intro) ? ( intro ) : (
-

Sign in to your admin panel

+

+ Sign {action === "login" ? "in" : "up"} to your admin panel +

Enter your credentials below to get access.

)} diff --git a/app/src/ui/routes/auth/auth.register.tsx b/app/src/ui/routes/auth/auth.register.tsx new file mode 100644 index 00000000..9b1e7e74 --- /dev/null +++ b/app/src/ui/routes/auth/auth.register.tsx @@ -0,0 +1,18 @@ +import { Logo } from "ui/components/display/Logo"; +import { Link } from "ui/components/wouter/Link"; +import { Auth } from "ui/elements"; +import { useBrowserTitle } from "ui/hooks/use-browser-title"; + +export function AuthRegister() { + useBrowserTitle(["Register"]); + return ( + + + + } + /> + ); +} diff --git a/app/src/ui/routes/auth/auth.strategies.tsx b/app/src/ui/routes/auth/auth.strategies.tsx index 24a23b26..7e68a961 100644 --- a/app/src/ui/routes/auth/auth.strategies.tsx +++ b/app/src/ui/routes/auth/auth.strategies.tsx @@ -24,6 +24,7 @@ import { Form, FormContextOverride, FormDebug, + HiddenField, ObjectField, Subscribe, useDerivedFieldContext, @@ -36,9 +37,16 @@ import * as AppShell from "../../layouts/AppShell/AppShell"; export function AuthStrategiesList(props) { useBrowserTitle(["Auth", "Strategies"]); - const { hasSecrets } = useBknd({ withSecrets: true }); + const { + hasSecrets, + config: { + auth: { enabled }, + }, + } = useBknd({ withSecrets: true }); if (!hasSecrets) { return ; + } else if (!enabled) { + return ; } return ; @@ -62,7 +70,6 @@ function AuthStrategiesListInternal() { ); async function handleSubmit(data: any) { - console.log("submit", { strategies: data }); await $auth.actions.config.set({ strategies: data }); } @@ -152,7 +159,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => { {autoFormatString(name)}
- + { "flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4", )} > - +
)} @@ -176,7 +183,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => { ); }; -const StrategyToggle = () => { +const StrategyToggle = ({ type }: { type: StrategyProps["type"] }) => { const ctx = useDerivedFieldContext(""); const { value } = useFormValue(""); @@ -219,8 +226,10 @@ const OAUTH_BRANDS = { discord: TbBrandDiscordFilled, }; -const StrategyForm = ({ type }: Pick) => { - let Component = () => ; +const StrategyForm = ({ type, name }: Pick) => { + let Component = (p: any) => ( + + ); switch (type) { case "password": Component = StrategyPasswordForm; @@ -230,16 +239,22 @@ const StrategyForm = ({ type }: Pick) => { break; } - return ; + return ( + <> + + + + ); }; const StrategyPasswordForm = () => { return ; }; -const StrategyOAuthForm = () => { +const StrategyOAuthForm = ({ type, name }: Pick) => { return ( <> + diff --git a/app/src/ui/routes/index.tsx b/app/src/ui/routes/index.tsx index 27d40a08..bc86bc99 100644 --- a/app/src/ui/routes/index.tsx +++ b/app/src/ui/routes/index.tsx @@ -10,6 +10,7 @@ import MediaRoutes from "./media"; import { Root, RootEmpty } from "./root"; import SettingsRoutes from "./settings"; import { FlashMessage } from "ui/modules/server/FlashMessage"; +import { AuthRegister } from "ui/routes/auth/auth.register"; // @ts-ignore const TestRoutes = lazy(() => import("./test")); @@ -24,6 +25,7 @@ export function Routes() { + diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 00499951..7552e8fd 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -50,7 +50,7 @@ if (example) { } let app: App; -const recreate = import.meta.env.VITE_APP_DISABLE_FRESH !== "1"; +const recreate = import.meta.env.VITE_APP_FRESH === "1"; let firstStart = true; export default { async fetch(request: Request) { diff --git a/bun.lock b/bun.lock index 5da98e03..a35322ec 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "app": { "name": "bknd", - "version": "0.11.0-rc.2", + "version": "0.11.0", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -43,6 +43,7 @@ "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", "aws4fetch": "^1.0.20", + "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", "hono": "^4.7.4", @@ -132,6 +133,7 @@ "version": "0.5.1", "devDependencies": { "@types/bun": "latest", + "bknd": "workspace:*", "tsdx": "^0.14.1", "typescript": "^5.0.0", }, @@ -1228,7 +1230,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.2.8", "", { "dependencies": { "bun-types": "1.2.7" } }, "sha512-t8L1RvJVUghW5V+M/fL3Thbxcs0HwNsXsnTEBEfEVqGteiJToOlZ/fyOEaR1kZsNqnu+3XA4RI/qmnX4w6+S+w=="], + "@types/bun": ["@types/bun@1.2.9", "", { "dependencies": { "bun-types": "1.2.9" } }, "sha512-epShhLGQYc4Bv/aceHbmBhOz1XgUnuTZgcxjxk+WXwNyDXavv5QHD1QEFV0FwbTSQtNq6g4ZcV6y0vZakTjswg=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -1572,7 +1574,7 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], - "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -4064,7 +4066,7 @@ "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], - "@types/bun/bun-types": ["bun-types@1.2.7", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-P4hHhk7kjF99acXqKvltyuMQ2kf/rzIw3ylEDpCxDS9Xa0X0Yp/gJu/vDCucmWpiur5qJ0lwB2bWzOXa2GlHqA=="], + "@types/bun/bun-types": ["bun-types@1.2.9", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw=="], "@types/jest/jest-diff": ["jest-diff@25.5.0", "", { "dependencies": { "chalk": "^3.0.0", "diff-sequences": "^25.2.6", "jest-get-type": "^25.2.6", "pretty-format": "^25.5.0" } }, "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A=="], @@ -4660,6 +4662,8 @@ "verdaccio-htpasswd/@verdaccio/file-locking": ["@verdaccio/file-locking@13.0.0-next-8.0", "", { "dependencies": { "lockfile": "1.0.4" } }, "sha512-28XRwpKiE3Z6KsnwE7o8dEM+zGWOT+Vef7RVJyUlG176JVDbGGip3HfCmFioE1a9BklLyGEFTu6D69BzfbRkzA=="], + "verdaccio-htpasswd/bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "verdaccio-htpasswd/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "vite/esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="],