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:
@@ -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.
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user