fix(cms/admin): restore login form — IAM-only had no browser sign-in

The admin login rendered only the logo: Users.disableLocalStrategy hid the
email/password form (LoginView gates <LoginForm> on !disableLocalStrategy) and
no beforeLogin/SSO component or OIDC callback was ever wired, so there was no
way to sign in — the panel was a dead end (not a client mount hang).

Re-enable the local (email/password) strategy so the form renders; the IAM
bearer/JWKS strategy stays layered on top for SSO/API. email now comes from the
local strategy (dropped the duplicate from iamAuthFields). prodMigrations adds
the local-auth columns (salt/hash/reset*/login_attempts/lock_until) + the
users_sessions table on boot. The seeded z@<domain> superuser signs in with a
framework-hashed password.
This commit is contained in:
hanzo-dev
2026-07-14 23:12:10 -07:00
parent 769d708195
commit a2fe39ad36
7 changed files with 1634 additions and 35 deletions
+10 -5
View File
@@ -3,9 +3,12 @@ import type { CollectionConfig } from '@hanzo/cms'
import { hanzoIAMStrategy, iamAuthFields } from '@hanzo/cms-auth-iam'
/**
* Users authenticate ONLY through Hanzo IAM SSO. The local email/password
* strategy is disabled — IAM is the sole identity authority. Users are
* provisioned on first login from verified IAM claims; org (owner) == tenant.
* Users authenticate through Hanzo IAM SSO (bearer/JWKS) AND, for the admin
* panel, the local email/password strategy — both coexist. IAM is the identity
* authority for every API/SSO user (provisioned on first login from verified
* claims; org (owner) == tenant); the local strategy exists solely so the
* seeded `z@<domain>` superuser can sign in at /admin/login without an external
* OIDC round-trip. Passwords are hashed by the framework — never stored plain.
*/
export const Users: CollectionConfig = {
slug: 'users',
@@ -13,11 +16,13 @@ export const Users: CollectionConfig = {
useAsTitle: 'email',
},
auth: {
disableLocalStrategy: true,
// Local (email/password) strategy stays ENABLED so the admin login view
// renders its form; the IAM bearer strategy is layered on top for SSO/API.
strategies: [hanzoIAMStrategy()],
},
fields: [
// email is added by the auth config
// `email` is supplied by the local auth strategy; iamAuthFields carries the
// IAM claim-mapping fields only.
...iamAuthFields,
],
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
import type { MigrateDownArgs, MigrateUpArgs } from '@hanzo/cms-db-sqlite'
import { sql } from '@hanzo/cms-db-sqlite'
/**
* Enable the local (email/password) auth strategy on `users` alongside the IAM
* bearer strategy. Adds the columns Payload's local strategy selects on every
* user query (a missing column would break ALL user reads) plus the sessions
* table. Written as an idempotent DELTA against the pre-existing schema (every
* other table already exists), so it applies to the live DB — never the
* full-schema form `migrate:create` emits from an empty baseline.
*/
const AUTH_COLUMNS: string[] = [
'`reset_password_token` text',
'`reset_password_expiration` text',
'`salt` text',
'`hash` text',
'`login_attempts` numeric DEFAULT 0',
'`lock_until` text',
]
export async function up({ db }: MigrateUpArgs): Promise<void> {
for (const col of AUTH_COLUMNS) {
try {
await db.run(sql.raw(`ALTER TABLE \`users\` ADD COLUMN ${col};`))
} catch (err) {
// idempotent: re-running over an already-migrated DB is a no-op
if (!/duplicate column name/i.test(err instanceof Error ? err.message : String(err))) {
throw err
}
}
}
await db.run(sql`CREATE TABLE IF NOT EXISTS \`users_sessions\` (
\`_order\` integer NOT NULL,
\`_parent_id\` integer NOT NULL,
\`id\` text PRIMARY KEY NOT NULL,
\`created_at\` text,
\`expires_at\` text NOT NULL,
FOREIGN KEY (\`_parent_id\`) REFERENCES \`users\`(\`id\`) ON UPDATE no action ON DELETE cascade
);`)
await db.run(
sql`CREATE INDEX IF NOT EXISTS \`users_sessions_order_idx\` ON \`users_sessions\` (\`_order\`);`,
)
await db.run(
sql`CREATE INDEX IF NOT EXISTS \`users_sessions_parent_id_idx\` ON \`users_sessions\` (\`_parent_id\`);`,
)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.run(sql`DROP TABLE IF EXISTS \`users_sessions\`;`)
// SQLite < 3.35 cannot DROP COLUMN; the added auth columns are nullable and
// inert once the local strategy is disabled again, so they are left in place.
}
+9
View File
@@ -0,0 +1,9 @@
import * as migration_20260715_055204_add_local_auth from './20260715_055204_add_local_auth'
export const migrations = [
{
name: '20260715_055204_add_local_auth',
down: migration_20260715_055204_add_local_auth.down,
up: migration_20260715_055204_add_local_auth.up,
},
]
+67 -22
View File
@@ -71,11 +71,11 @@ export interface Config {
tenants: Tenant;
pages: Page;
media: Media;
'cms-kv': CMSKv;
'cms-jobs': CMSJob;
'cms-locked-documents': CMSLockedDocument;
'cms-preferences': CMSPreference;
'cms-migrations': CMSMigration;
'cms-kv': CmsKv;
'cms-jobs': CmsJob;
'cms-locked-documents': CmsLockedDocument;
'cms-preferences': CmsPreference;
'cms-migrations': CmsMigration;
};
collectionsJoins: {};
collectionsSelect: {
@@ -83,11 +83,11 @@ export interface Config {
tenants: TenantsSelect<false> | TenantsSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'cms-kv': CMSKvSelect<false> | CMSKvSelect<true>;
'cms-jobs': CMSJobsSelect<false> | CMSJobsSelect<true>;
'cms-locked-documents': CMSLockedDocumentsSelect<false> | CMSLockedDocumentsSelect<true>;
'cms-preferences': CMSPreferencesSelect<false> | CMSPreferencesSelect<true>;
'cms-migrations': CMSMigrationsSelect<false> | CMSMigrationsSelect<true>;
'cms-kv': CmsKvSelect<false> | CmsKvSelect<true>;
'cms-jobs': CmsJobsSelect<false> | CmsJobsSelect<true>;
'cms-locked-documents': CmsLockedDocumentsSelect<false> | CmsLockedDocumentsSelect<true>;
'cms-preferences': CmsPreferencesSelect<false> | CmsPreferencesSelect<true>;
'cms-migrations': CmsMigrationsSelect<false> | CmsMigrationsSelect<true>;
};
db: {
defaultIDType: number;
@@ -135,7 +135,6 @@ export interface UserAuthOperations {
*/
export interface User {
id: number;
email?: string | null;
/**
* Hanzo IAM subject (user id).
*/
@@ -145,6 +144,22 @@ export interface User {
*/
iamOrg?: string | null;
username?: string | null;
/**
* Hanzo IAM platform admin (all-tenant access).
*/
isAdmin?: boolean | null;
/**
* Hanzo IAM group slugs.
*/
groups?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
tenants?:
| {
tenant: number | Tenant;
@@ -153,6 +168,21 @@ export interface User {
| null;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
@@ -222,7 +252,7 @@ export interface Media {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-kv".
*/
export interface CMSKv {
export interface CmsKv {
id: number;
key: string;
data:
@@ -239,7 +269,7 @@ export interface CMSKv {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-jobs".
*/
export interface CMSJob {
export interface CmsJob {
id: number;
/**
* Input data provided to the job
@@ -331,7 +361,7 @@ export interface CMSJob {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-locked-documents".
*/
export interface CMSLockedDocument {
export interface CmsLockedDocument {
id: number;
document?:
| ({
@@ -362,7 +392,7 @@ export interface CMSLockedDocument {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-preferences".
*/
export interface CMSPreference {
export interface CmsPreference {
id: number;
user: {
relationTo: 'users';
@@ -385,7 +415,7 @@ export interface CMSPreference {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-migrations".
*/
export interface CMSMigration {
export interface CmsMigration {
id: number;
name?: string | null;
batch?: number | null;
@@ -397,10 +427,11 @@ export interface CMSMigration {
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
email?: T;
iamSub?: T;
iamOrg?: T;
username?: T;
isAdmin?: T;
groups?: T;
tenants?:
| T
| {
@@ -409,6 +440,20 @@ export interface UsersSelect<T extends boolean = true> {
};
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -457,7 +502,7 @@ export interface MediaSelect<T extends boolean = true> {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-kv_select".
*/
export interface CMSKvSelect<T extends boolean = true> {
export interface CmsKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
@@ -465,7 +510,7 @@ export interface CMSKvSelect<T extends boolean = true> {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-jobs_select".
*/
export interface CMSJobsSelect<T extends boolean = true> {
export interface CmsJobsSelect<T extends boolean = true> {
input?: T;
taskStatus?: T;
completedAt?: T;
@@ -496,7 +541,7 @@ export interface CMSJobsSelect<T extends boolean = true> {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-locked-documents_select".
*/
export interface CMSLockedDocumentsSelect<T extends boolean = true> {
export interface CmsLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
@@ -507,7 +552,7 @@ export interface CMSLockedDocumentsSelect<T extends boolean = true> {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-preferences_select".
*/
export interface CMSPreferencesSelect<T extends boolean = true> {
export interface CmsPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
@@ -518,7 +563,7 @@ export interface CMSPreferencesSelect<T extends boolean = true> {
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cms-migrations_select".
*/
export interface CMSMigrationsSelect<T extends boolean = true> {
export interface CmsMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
+4
View File
@@ -13,6 +13,7 @@ import { Media } from './collections/Media.js'
import { Pages } from './collections/Pages.js'
import { Tenants } from './collections/Tenants.js'
import { Users } from './collections/Users.js'
import { migrations } from './migrations/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -41,10 +42,13 @@ export default buildConfig({
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
// DB = Hanzo Base / SQLite (per-org). libsql; no Postgres/Mongo default.
// prodMigrations run once on boot in production (the dev-only schema push is
// skipped there) — this is how the local-auth columns reach the live DB.
db: sqliteAdapter({
client: {
url: process.env.DATABASE_URI || `file:${path.resolve(dirname, `../data/${ORG}.db`)}`,
},
prodMigrations: migrations,
}),
plugins: [
// Media/DAM -> SeaweedFS (hanzoai/s3), per-org prefix. forcePathStyle required.
+4 -8
View File
@@ -8,16 +8,12 @@ export type { HanzoIAMStrategyConfig, IAMClaims } from './types.js'
* Spread these into your users collection `fields`.
*
* fields: [...iamAuthFields, ...yourFields]
*
* NOTE: `email` is intentionally NOT here — the local auth strategy adds it.
* A collection with `disableLocalStrategy: true` must add an `email` field
* itself.
*/
export const iamAuthFields: Field[] = [
{
// Auth collections with `disableLocalStrategy: true` do NOT get the email
// field auto-added (that comes from the local strategy), so add it here.
name: 'email',
type: 'email',
index: true,
label: 'Email',
},
{
name: 'iamSub',
type: 'text',