@elytracms/studio 0.0.47 → 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/convex/auth.ts +61 -12
  2. package/convex/authority.ts +40 -2
  3. package/convex/functions.ts +9 -0
  4. package/convex/invites.ts +75 -0
  5. package/convex/members.ts +44 -18
  6. package/convex/schema.ts +37 -0
  7. package/convex/users.ts +89 -2
  8. package/dist/components/account/account-settings.d.ts +13 -0
  9. package/dist/components/account/account-settings.js +54 -0
  10. package/dist/components/account/account-settings.js.map +1 -0
  11. package/dist/components/account/credential-panel.d.ts +14 -0
  12. package/dist/components/account/credential-panel.js +52 -0
  13. package/dist/components/account/credential-panel.js.map +1 -0
  14. package/dist/components/account/self-account-schema.d.ts +19 -0
  15. package/dist/components/account/self-account-schema.js +52 -0
  16. package/dist/components/account/self-account-schema.js.map +1 -0
  17. package/dist/components/account/self-account-view-source.d.ts +35 -0
  18. package/dist/components/account/self-account-view-source.js +110 -0
  19. package/dist/components/account/self-account-view-source.js.map +1 -0
  20. package/dist/components/auth-context.d.ts +5 -1
  21. package/dist/components/auth-context.js +9 -0
  22. package/dist/components/auth-context.js.map +1 -1
  23. package/dist/components/current-user-menu.js +11 -2
  24. package/dist/components/current-user-menu.js.map +1 -1
  25. package/dist/components/deployment-setup-gate.d.ts +31 -0
  26. package/dist/components/deployment-setup-gate.js +92 -0
  27. package/dist/components/deployment-setup-gate.js.map +1 -0
  28. package/dist/components/project-session.d.ts +1 -1
  29. package/dist/components/project-session.js.map +1 -1
  30. package/dist/components/use-hydration-phase.js +1 -1
  31. package/dist/components/use-hydration-phase.js.map +1 -1
  32. package/dist/components/users/invite-user-bar.d.ts +3 -0
  33. package/dist/components/users/invite-user-bar.js +91 -0
  34. package/dist/components/users/invite-user-bar.js.map +1 -0
  35. package/dist/components/users/users-module.js +13 -2
  36. package/dist/components/users/users-module.js.map +1 -1
  37. package/dist/lib/auth/auth-adapter.d.ts +50 -2
  38. package/dist/lib/auth/auth-adapter.js.map +1 -1
  39. package/dist/lib/auth/convex-auth-adapter.d.ts +28 -1
  40. package/dist/lib/auth/convex-auth-adapter.js +113 -1
  41. package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
  42. package/dist/lib/auth/dev-auto-login-adapter.d.ts +3 -1
  43. package/dist/lib/auth/dev-auto-login-adapter.js +6 -0
  44. package/dist/lib/auth/dev-auto-login-adapter.js.map +1 -1
  45. package/dist/lib/auth/index.d.ts +2 -2
  46. package/dist/lib/auth/index.js +1 -1
  47. package/dist/lib/auth/index.js.map +1 -1
  48. package/dist/lib/auth/local-auth-adapter.d.ts +3 -1
  49. package/dist/lib/auth/local-auth-adapter.js +16 -0
  50. package/dist/lib/auth/local-auth-adapter.js.map +1 -1
  51. package/dist/lib/i18n/messages.en.js +45 -0
  52. package/dist/lib/i18n/messages.en.js.map +1 -1
  53. package/dist/lib/operations/studio-operations.js +1 -1
  54. package/dist/lib/operations/studio-operations.js.map +1 -1
  55. package/dist/lib/persistence/backend-mode.d.ts +1 -1
  56. package/dist/lib/persistence/backend-mode.js +1 -1
  57. package/dist/lib/persistence/backend-mode.js.map +1 -1
  58. package/dist/lib/persistence/convex-adapter.d.ts +2 -1
  59. package/dist/lib/persistence/convex-adapter.js +69 -1
  60. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  61. package/dist/lib/persistence/deployment-readiness.d.ts +36 -0
  62. package/dist/lib/persistence/deployment-readiness.js +26 -0
  63. package/dist/lib/persistence/deployment-readiness.js.map +1 -0
  64. package/dist/lib/persistence/index.d.ts +3 -1
  65. package/dist/lib/persistence/index.js +4 -1
  66. package/dist/lib/persistence/index.js.map +1 -1
  67. package/dist/lib/workspace/switcher-source.js +1 -1
  68. package/dist/lib/workspace/switcher-source.js.map +1 -1
  69. package/dist/lib/workspace/workspace-source.d.ts +1 -1
  70. package/dist/lib/workspace/workspace-source.js +1 -1
  71. package/dist/lib/workspace/workspace-source.js.map +1 -1
  72. package/package.json +2 -2
package/convex/auth.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { convexAuth } from '@convex-dev/auth/server'
2
2
  import { Password } from '@convex-dev/auth/providers/Password'
3
+ import { ConvexError } from 'convex/values'
4
+ import { classifyInviteSignup, normalizeInviteEmail } from '@elytracms/core/persistence'
3
5
 
4
6
  /** The provider list `convexAuth` accepts, derived from its own signature. */
5
7
  type ConvexAuthProviders = Parameters<typeof convexAuth>[0]['providers']
@@ -29,21 +31,68 @@ export function defineStudioAuth(options?: { providers?: ConvexAuthProviders })
29
31
  providers: options?.providers ?? [Password],
30
32
  callbacks: {
31
33
  /**
32
- * First-principal admin bootstrap (EC-182 follow-up). On a freshly-provisioned
33
- * deployment the `members` table is empty, so no one can pass the role guard and
34
- * the studio is unreachable. Rather than require a manual seed step, the **first
35
- * user to authenticate while the deployment has no members** is auto-granted
36
- * `admin`. Once any membership exists this is a no-op (one indexed read) — further
37
- * roles are assigned by an admin via `members:set`. Single-tenant: one `members`
38
- * table per deployment. `beforeSessionCreation` fires on every sign-in (not just
39
- * account creation), so it also recovers a member-less account and never lets a
40
- * deployment lock itself out.
34
+ * Invite-based onboarding gate + first-principal bootstrap (EC-324 / EC-150).
35
+ * Fires on EVERY sign-in (not just account creation), right before the session
36
+ * is persisted — throwing here rejects the sign-in. The ONE decision lives in
37
+ * the pure `classifyInviteSignup` (shared with its tests):
38
+ *
39
+ * - **existing member** → normal re-sign-in; no-op (also recovers a member-less
40
+ * account it created earlier and never lets the deployment lock itself out).
41
+ * - **first user** (no members yet) → bootstrap as `admin` (was the only case).
42
+ * - **invited** → grant the invited role + CONSUME the invite (delete the row).
43
+ * - **uninvited** → REJECT. This closes the open-sign-up hole (EC-304): an
44
+ * anonymous person can no longer self-register into the deployment. The
45
+ * `ConvexError` message ("not-invited: …") survives to the client even in
46
+ * production, so the sign-in screen tells the truth.
47
+ *
48
+ * Single-tenant: one `members` table per deployment. Invite mechanism: a plain
49
+ * email allowlist (`invites` table) — the simplest server-enforced design, no
50
+ * token infrastructure.
41
51
  */
42
52
  async beforeSessionCreation(ctx, { userId }) {
43
- const existing = await ctx.db.query('members').first()
44
- if (existing) return
45
53
  const now = new Date().toISOString()
46
- await ctx.db.insert('members', { userId, role: 'admin', createdAt: now, updatedAt: now })
54
+ // The convexAuth callback ctx is `AnyDataModel` (not schema-bound), so use
55
+ // `.filter()` rather than `.withIndex()` here. The members/invites tables are
56
+ // tiny (single-tenant), and this runs only at sign-in.
57
+ const existingMember = await ctx.db
58
+ .query('members')
59
+ .filter((q) => q.eq(q.field('userId'), userId))
60
+ .first()
61
+ const anyMember = existingMember ?? (await ctx.db.query('members').first())
62
+ const user = await ctx.db.get(userId)
63
+ const rawEmail = (user as { email?: string } | null)?.email
64
+ const email = rawEmail ? normalizeInviteEmail(rawEmail) : null
65
+ const invite = email
66
+ ? await ctx.db
67
+ .query('invites')
68
+ .filter((q) => q.eq(q.field('email'), email))
69
+ .first()
70
+ : null
71
+
72
+ const decision = classifyInviteSignup({
73
+ hasAnyMember: anyMember !== null,
74
+ existingRole: existingMember?.role ?? null,
75
+ invite: invite ? { role: invite.role } : null,
76
+ })
77
+
78
+ switch (decision.kind) {
79
+ case 'existing-member':
80
+ return
81
+ case 'bootstrap-admin':
82
+ await ctx.db.insert('members', { userId, role: 'admin', createdAt: now, updatedAt: now })
83
+ return
84
+ case 'grant-role':
85
+ await ctx.db.insert('members', {
86
+ userId,
87
+ role: decision.role,
88
+ createdAt: now,
89
+ updatedAt: now,
90
+ })
91
+ if (invite) await ctx.db.delete(invite._id)
92
+ return
93
+ case 'reject':
94
+ throw new ConvexError(decision.reason)
95
+ }
47
96
  },
48
97
  },
49
98
  })
@@ -1,6 +1,11 @@
1
1
  import { checkAuthorityFloor } from '@elytracms/core/operations'
2
- import { defaultRolePolicy, roleGrantsCapability } from '@elytracms/core/persistence'
3
- import type { ProjectRole } from '@elytracms/core/persistence'
2
+ import {
3
+ buildAuthorityAuditEntry,
4
+ defaultRolePolicy,
5
+ roleGrantsCapability,
6
+ } from '@elytracms/core/persistence'
7
+ import type { AuthorityAuditOutcome, ProjectRole } from '@elytracms/core/persistence'
8
+ import type { MutationCtx } from './functions'
4
9
  import { memberRole, type GuardCtx } from './guard'
5
10
 
6
11
  /**
@@ -58,3 +63,36 @@ export async function assertAuthorityFloor(
58
63
  )
59
64
  if (!decision.allow) throw new Error(`unauthorized: ${decision.reason}`)
60
65
  }
66
+
67
+ /**
68
+ * Append one {@link buildAuthorityAuditEntry} row to the `authorityAudit` table
69
+ * (EC-240 H-tail item 6). Called at the floor chokepoint (`members:set` /
70
+ * `members:remove`) AFTER a COMMITTED authority change (`outcome: 'allowed'`).
71
+ * Best-effort provenance: `op` is the floor operation even when reached via the
72
+ * users rail (`users.save`/`remove` delegate to it). Denied attempts are NOT
73
+ * recorded here — a throwing mutation rolls back its transaction, this insert
74
+ * included (see `members.ts`); logging denials needs an out-of-transaction sink
75
+ * (deferred). No surfacing UI (deferred).
76
+ */
77
+ export async function recordAuthorityAudit(
78
+ ctx: MutationCtx,
79
+ input: {
80
+ readonly actorId: string
81
+ readonly targetUserId: string
82
+ readonly op: string
83
+ readonly outcome: AuthorityAuditOutcome
84
+ readonly rolesGranted?: readonly string[]
85
+ readonly detail?: string
86
+ },
87
+ ): Promise<void> {
88
+ const entry = buildAuthorityAuditEntry({
89
+ actorId: input.actorId,
90
+ targetUserId: input.targetUserId,
91
+ op: input.op,
92
+ outcome: input.outcome,
93
+ ...(input.rolesGranted !== undefined ? { rolesGranted: input.rolesGranted } : {}),
94
+ ...(input.detail !== undefined ? { detail: input.detail } : {}),
95
+ at: new Date().toISOString(),
96
+ })
97
+ await ctx.db.insert('authorityAudit', entry)
98
+ }
@@ -1,5 +1,6 @@
1
1
  import { customCtx, customMutation } from 'convex-helpers/server/customFunctions'
2
2
  import {
3
+ actionGeneric,
3
4
  httpActionGeneric,
4
5
  internalMutationGeneric,
5
6
  internalQueryGeneric,
@@ -27,6 +28,14 @@ export type QueryCtx = GenericQueryCtx<DataModel>
27
28
  export type MutationCtx = GenericMutationCtx<DataModel>
28
29
 
29
30
  export const query = queryGeneric as QueryBuilder<DataModel, 'public'>
31
+ /**
32
+ * Public action builder (EC-240 H-tail). Actions reach data only through
33
+ * `ctx.runQuery`/`runMutation` and may call provider helpers that run their own
34
+ * mutations (e.g. Convex Auth's `retrieveAccount`/`modifyAccountCredentials` for
35
+ * the self-service password change). Not DataModel-bound in the trigger sense —
36
+ * an action never writes `ctx.db` directly, so it bypasses the write chokepoint.
37
+ */
38
+ export const action = actionGeneric
30
39
  /**
31
40
  * EC-286: every mutation runs through the write-chokepoint triggers (`triggers.ts`)
32
41
  * via `customCtx(triggers.wrapDB)`, so reference-index maintenance and host
@@ -0,0 +1,75 @@
1
+ import { v } from 'convex/values'
2
+ import { mutation, query } from './functions'
3
+ import { requireCapability } from './guard'
4
+
5
+ /**
6
+ * Invite allowlist management (EC-324). An admin invites an email + role; the
7
+ * invite is CONSUMED at first sign-up by the auth boundary (`auth.ts`
8
+ * `beforeSessionCreation`). Management is **admin-only, server-side** —
9
+ * `requireCapability('project.administer')`, the same trust boundary as
10
+ * member/user management; UI gating alone is never trusted.
11
+ *
12
+ * The invited `role` is validated against the active policy at the operations
13
+ * write boundary (`invites.create` input); this store keeps it a lenient string
14
+ * (schema-as-lens, EC-277). Keyed by normalized (lowercased) email; `create`
15
+ * upserts so re-inviting the same email replaces the pending role.
16
+ */
17
+
18
+ const normalizeEmail = (email: string): string => email.trim().toLowerCase()
19
+
20
+ export const list = query({
21
+ args: {},
22
+ handler: async (ctx) => {
23
+ await requireCapability(ctx, 'project.administer')
24
+ const rows = await ctx.db.query('invites').collect()
25
+ return rows.sort((a, b) => (a.email < b.email ? -1 : a.email > b.email ? 1 : 0))
26
+ },
27
+ })
28
+
29
+ export const create = mutation({
30
+ args: {
31
+ email: v.string(),
32
+ role: v.string(),
33
+ invitedBy: v.optional(v.string()),
34
+ createdAt: v.string(),
35
+ updatedAt: v.string(),
36
+ },
37
+ handler: async (ctx, args) => {
38
+ await requireCapability(ctx, 'project.administer')
39
+ const email = normalizeEmail(args.email)
40
+ const existing = await ctx.db
41
+ .query('invites')
42
+ .withIndex('by_email', (q) => q.eq('email', email))
43
+ .first()
44
+ if (existing) {
45
+ await ctx.db.patch(existing._id, {
46
+ role: args.role,
47
+ updatedAt: args.updatedAt,
48
+ ...(args.invitedBy !== undefined ? { invitedBy: args.invitedBy } : {}),
49
+ })
50
+ return ctx.db.get(existing._id)
51
+ }
52
+ const id = await ctx.db.insert('invites', {
53
+ email,
54
+ role: args.role,
55
+ ...(args.invitedBy !== undefined ? { invitedBy: args.invitedBy } : {}),
56
+ createdAt: args.createdAt,
57
+ updatedAt: args.updatedAt,
58
+ })
59
+ return ctx.db.get(id)
60
+ },
61
+ })
62
+
63
+ export const revoke = mutation({
64
+ args: { email: v.string() },
65
+ handler: async (ctx, { email }) => {
66
+ await requireCapability(ctx, 'project.administer')
67
+ const key = normalizeEmail(email)
68
+ const existing = await ctx.db
69
+ .query('invites')
70
+ .withIndex('by_email', (q) => q.eq('email', key))
71
+ .first()
72
+ if (!existing) throw new Error(`not-found: invite "${key}"`)
73
+ await ctx.db.delete(existing._id)
74
+ },
75
+ })
package/convex/members.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { v } from 'convex/values'
2
2
  import { mutation, query } from './functions'
3
3
  import { getOptionalUserId, requireCapability, requireUserId } from './guard'
4
- import { assertAuthorityFloor } from './authority'
4
+ import { assertAuthorityFloor, recordAuthorityAudit } from './authority'
5
5
 
6
6
  /**
7
7
  * Deployment membership persistence (EC-150, single-tenant per EC-179). The
@@ -76,6 +76,13 @@ export const set = mutation({
76
76
  handler: async (ctx, args) => {
77
77
  const callerId = await requireCapability(ctx, 'project.administer')
78
78
  // Integrity floor (EC-240): can't demote the last admin / assign above self.
79
+ // Throws `unauthorized: …` on denial — a first-class, visible error (verified
80
+ // live). NOTE (EC-240 H-tail item 6): a denied attempt is NOT audited here. A
81
+ // Convex mutation that throws rolls back its ENTIRE transaction, so any audit
82
+ // insert in a catch would roll back with it (confirmed live — the denied rows
83
+ // never persisted). The audit therefore records only COMMITTED authority changes;
84
+ // durably logging *denied* attempts needs an out-of-transaction sink (an action
85
+ // wrapper / separate log), deferred with the surfacing UI.
79
86
  await assertAuthorityFloor(ctx, {
80
87
  callerId,
81
88
  targetUserId: args.userId,
@@ -86,23 +93,34 @@ export const set = mutation({
86
93
  .query('members')
87
94
  .withIndex('by_user', (q) => q.eq('userId', args.userId))
88
95
  .first()
89
- if (existing) {
90
- await ctx.db.patch(existing._id, {
91
- role: args.role,
92
- updatedAt: args.updatedAt,
93
- // Only touch `meta` when supplied — a bare role change preserves it.
94
- ...(args.meta !== undefined ? { meta: args.meta } : {}),
95
- })
96
- return ctx.db.get(existing._id)
97
- }
98
- const id = await ctx.db.insert('members', {
99
- userId: args.userId,
100
- role: args.role,
101
- ...(args.meta !== undefined ? { meta: args.meta } : {}),
102
- createdAt: args.createdAt,
103
- updatedAt: args.updatedAt,
96
+ const result = existing
97
+ ? await (async () => {
98
+ await ctx.db.patch(existing._id, {
99
+ role: args.role,
100
+ updatedAt: args.updatedAt,
101
+ // Only touch `meta` when supplied — a bare role change preserves it.
102
+ ...(args.meta !== undefined ? { meta: args.meta } : {}),
103
+ })
104
+ return ctx.db.get(existing._id)
105
+ })()
106
+ : await (async () => {
107
+ const id = await ctx.db.insert('members', {
108
+ userId: args.userId,
109
+ role: args.role,
110
+ ...(args.meta !== undefined ? { meta: args.meta } : {}),
111
+ createdAt: args.createdAt,
112
+ updatedAt: args.updatedAt,
113
+ })
114
+ return ctx.db.get(id)
115
+ })()
116
+ await recordAuthorityAudit(ctx, {
117
+ actorId: callerId,
118
+ targetUserId: args.userId,
119
+ op: 'members.set',
120
+ outcome: 'allowed',
121
+ rolesGranted: [args.role],
104
122
  })
105
- return ctx.db.get(id)
123
+ return result
106
124
  },
107
125
  })
108
126
 
@@ -110,7 +128,9 @@ export const remove = mutation({
110
128
  args: { userId: v.string() },
111
129
  handler: async (ctx, { userId }) => {
112
130
  const callerId = await requireCapability(ctx, 'project.administer')
113
- // Integrity floor (EC-240): the last admin cannot be removed.
131
+ // Integrity floor (EC-240): the last admin cannot be removed. Throws on denial
132
+ // (a visible error). Denied attempts are not audited — see the `set` note above
133
+ // (a throwing mutation rolls back its transaction, audit insert included).
114
134
  await assertAuthorityFloor(ctx, {
115
135
  callerId,
116
136
  targetUserId: userId,
@@ -123,5 +143,11 @@ export const remove = mutation({
123
143
  .first()
124
144
  if (!existing) throw new Error(`not-found: member "${userId}"`)
125
145
  await ctx.db.delete(existing._id)
146
+ await recordAuthorityAudit(ctx, {
147
+ actorId: callerId,
148
+ targetUserId: userId,
149
+ op: 'members.remove',
150
+ outcome: 'allowed',
151
+ })
126
152
  },
127
153
  })
package/convex/schema.ts CHANGED
@@ -72,6 +72,43 @@ export default defineSchema({
72
72
  updatedAt: v.string(),
73
73
  }).index('by_user', ['userId']),
74
74
 
75
+ /**
76
+ * Invite allowlist (EC-324) — invite-based onboarding. An admin pre-authorizes
77
+ * an email + role; the row is CONSUMED (deleted) when that email first signs up.
78
+ * Keyed by normalized (lowercased) email. Admin-gated through the operations
79
+ * chokepoint + `guard.ts`; the Convex auth boundary (`auth.ts`
80
+ * `beforeSessionCreation`) reads it to gate open sign-up (the EC-304 hole) and
81
+ * grant the invited role. `invitedBy` is the acting admin (audit provenance).
82
+ */
83
+ invites: defineTable({
84
+ email: v.string(),
85
+ role: v.string(),
86
+ invitedBy: v.optional(v.string()),
87
+ createdAt: v.string(),
88
+ updatedAt: v.string(),
89
+ }).index('by_email', ['email']),
90
+
91
+ /**
92
+ * Authority audit log (EC-240 H-tail item 6) — append-only. One row per
93
+ * COMMITTED authority-mutating op at the integrity-floor chokepoint
94
+ * (`members.set` / `members.remove`, and therefore `users.save` / `users.remove`
95
+ * which delegate to them): actor, target, op, outcome, granted role(s), timestamp.
96
+ * `outcome` is currently always `allowed`: a DENIED op throws, and a throwing
97
+ * Convex mutation rolls back its whole transaction (audit insert included), so
98
+ * denied attempts can't be logged in-transaction (verified live). Durably logging
99
+ * denials needs an out-of-transaction sink — deferred with the surfacing UI. The
100
+ * `denied` outcome stays in the shared vocabulary for that future sink.
101
+ */
102
+ authorityAudit: defineTable({
103
+ actorId: v.string(),
104
+ targetUserId: v.string(),
105
+ op: v.string(),
106
+ outcome: v.string(),
107
+ rolesGranted: v.array(v.string()),
108
+ detail: v.optional(v.string()),
109
+ at: v.string(),
110
+ }).index('by_time', ['at']),
111
+
75
112
  /**
76
113
  * Revocable CLI sync tokens (EC-150, consumed by the EC-147 push CLI).
77
114
  * Only the SHA-256 hash of a token is ever stored; revoked tokens keep
package/convex/users.ts CHANGED
@@ -1,6 +1,11 @@
1
- import { getAuthUserId } from '@convex-dev/auth/server'
1
+ import {
2
+ getAuthUserId,
3
+ modifyAccountCredentials,
4
+ retrieveAccount,
5
+ } from '@convex-dev/auth/server'
6
+ import { makeFunctionReference } from 'convex/server'
2
7
  import { v } from 'convex/values'
3
- import { query } from './functions'
8
+ import { action, mutation, query } from './functions'
4
9
  import type { QueryCtx } from './functions'
5
10
  import { requireCapability } from './guard'
6
11
 
@@ -10,6 +15,9 @@ import { requireCapability } from './guard'
10
15
  * `AuthSession`. Returns `null` when the caller is unauthenticated — never an
11
16
  * error, so the adapter can treat it as "signed out".
12
17
  */
18
+ /** The `users:me` wire shape (also the return type of the {@link meRef} ref). */
19
+ type MeResult = { id: string; email: string; name?: string }
20
+
13
21
  export const me = query({
14
22
  args: {},
15
23
  handler: async (ctx) => {
@@ -84,3 +92,82 @@ export const get = query({
84
92
  return joinUser(ctx, member)
85
93
  },
86
94
  })
95
+
96
+ /**
97
+ * The signed-in caller's OWN user projection (EC-240 H-tail item 1) — the
98
+ * self-permitted account-settings read. Distinct from the admin-only `get`/`list`:
99
+ * it takes NO target id and derives the caller from the JWT (`getAuthUserId`), so
100
+ * a non-admin can read ONLY their own record and never another's. Returns `null`
101
+ * when unauthenticated or not a member.
102
+ */
103
+ export const getSelf = query({
104
+ args: {},
105
+ handler: async (ctx) => {
106
+ const userId = await getAuthUserId(ctx)
107
+ if (userId === null) return null
108
+ const member = await ctx.db
109
+ .query('members')
110
+ .withIndex('by_user', (q) => q.eq('userId', userId))
111
+ .first()
112
+ if (!member) return null
113
+ return joinUser(ctx, member)
114
+ },
115
+ })
116
+
117
+ /**
118
+ * Self-service identity edit (EC-240 H-tail item 2). Patches the caller's OWN
119
+ * auth `users` row — name / avatar (`image`). Self-scoped by construction: it
120
+ * derives the target from `getAuthUserId`, so it can never touch another user's
121
+ * identity. Auth-owned fields never flow through the members-rail `members:set`.
122
+ */
123
+ export const updateProfile = mutation({
124
+ args: { name: v.optional(v.string()), avatar: v.optional(v.string()) },
125
+ handler: async (ctx, args) => {
126
+ const userId = await getAuthUserId(ctx)
127
+ if (userId === null) throw new Error('unauthorized: this function requires an authenticated user')
128
+ const patch: { name?: string; image?: string } = {}
129
+ if (args.name !== undefined) patch.name = args.name
130
+ if (args.avatar !== undefined) patch.image = args.avatar
131
+ if (Object.keys(patch).length > 0) await ctx.db.patch(userId, patch)
132
+ return null
133
+ },
134
+ })
135
+
136
+ /** The self identity read used by the password-change action (email = the
137
+ * Password provider's account id). String ref — package convex code never
138
+ * imports `./_generated/api` (see `sync.ts`). */
139
+ const meRef = makeFunctionReference<'query', Record<string, never>, MeResult | null>('users:me')
140
+
141
+ /**
142
+ * Self-service password change (EC-240 H-tail item 3), provider-delegated. An
143
+ * ACTION because it uses Convex Auth's `retrieveAccount` (verify the CURRENT
144
+ * password) + `modifyAccountCredentials` (set the new one), which run their own
145
+ * mutations. **Seam only:** the raw password values are handed straight to the
146
+ * provider and are NEVER logged or persisted by this code. Self-scoped: it
147
+ * derives the caller from the JWT and cross-checks the retrieved account is the
148
+ * caller's own before modifying.
149
+ */
150
+ export const changePassword = action({
151
+ args: { currentPassword: v.string(), newPassword: v.string() },
152
+ handler: async (ctx, { currentPassword, newPassword }) => {
153
+ const userId = await getAuthUserId(ctx)
154
+ if (userId === null) throw new Error('unauthorized: this function requires an authenticated user')
155
+ // Enforce the provider's default password policy (min length) — the reset
156
+ // flow validates it, but `modifyAccountCredentials` does not.
157
+ if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
158
+ const me = await ctx.runQuery(meRef, {})
159
+ if (!me?.email) throw new Error('unauthorized: no account email on file')
160
+ // Verify the CURRENT password; throws on mismatch (→ invalid-credentials).
161
+ const { user } = await retrieveAccount(ctx, {
162
+ provider: 'password',
163
+ account: { id: me.email, secret: currentPassword },
164
+ })
165
+ // Defense in depth: the retrieved account MUST be the caller's own.
166
+ if (String(user._id) !== String(userId)) throw new Error('unauthorized: account mismatch')
167
+ await modifyAccountCredentials(ctx, {
168
+ provider: 'password',
169
+ account: { id: me.email, secret: newPassword },
170
+ })
171
+ return null
172
+ },
173
+ })
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The **account-settings** surface (EC-240 H-tail items 1–4). A member — admin or
3
+ * not — opens their OWN record here (via the user menu → `users/<selfId>`). It is
4
+ * self-permitted: the self record is read through `users.getSelf` (never the
5
+ * admin-only `users.list`/`get`), so a non-admin reaches ONLY their own account.
6
+ *
7
+ * Reuses the portable edit shell (AD-19) for the identity form (name/avatar
8
+ * editable → `updateProfile`; email/role read-only) and appends the credential
9
+ * seam (change password). No bespoke form — the shell + the self source carry it.
10
+ */
11
+ export declare function AccountSettings({ projectId }: {
12
+ projectId: string;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { UserCog } from 'lucide-react';
3
+ import { Heading, Stack, Text } from '@elytracms/studio/ui';
4
+ import { listStoreState, useOperationQuery } from '@elytracms/studio/lib/query';
5
+ import { translate } from '@elytracms/studio/lib/i18n';
6
+ import { useAuth } from '../auth-context';
7
+ import { PortableDetailEditor } from '../cms/detail-editor';
8
+ import { CredentialPanel } from './credential-panel';
9
+ import { useSelfAccountViewSource } from './self-account-view-source';
10
+ /**
11
+ * The **account-settings** surface (EC-240 H-tail items 1–4). A member — admin or
12
+ * not — opens their OWN record here (via the user menu → `users/<selfId>`). It is
13
+ * self-permitted: the self record is read through `users.getSelf` (never the
14
+ * admin-only `users.list`/`get`), so a non-admin reaches ONLY their own account.
15
+ *
16
+ * Reuses the portable edit shell (AD-19) for the identity form (name/avatar
17
+ * editable → `updateProfile`; email/role read-only) and appends the credential
18
+ * seam (change password). No bespoke form — the shell + the self source carry it.
19
+ */
20
+ export function AccountSettings({ projectId }) {
21
+ const auth = useAuth();
22
+ const userId = auth.session?.user?.id ?? null;
23
+ const query = useOperationQuery({
24
+ queryKey: ['account', projectId, userId ?? '∅'],
25
+ loader: async (operations) => {
26
+ const result = await operations.users.getSelf({ projectId });
27
+ if (!result.ok)
28
+ throw new Error(result.error.message);
29
+ return result.value ?? null;
30
+ },
31
+ enabled: userId !== null,
32
+ });
33
+ const { status, errorMessage } = listStoreState(query);
34
+ const self = query.data ?? null;
35
+ if (status === 'loading') {
36
+ return _jsx(CenteredNote, { children: translate('account.loading') });
37
+ }
38
+ if (status === 'error') {
39
+ return (_jsx(CenteredNote, { testId: "account-error", children: errorMessage ?? translate('account.loadFailed') }));
40
+ }
41
+ if (!self) {
42
+ // Authenticated but not a member (no role yet) — honest, not a raw auth error.
43
+ return (_jsxs(Stack, { gap: "2", align: "center", justify: "center", className: "h-full p-8 text-center", children: [_jsx(UserCog, { className: "size-6 text-muted-foreground" }), _jsx(Heading, { level: 2, size: "h3", children: translate('account.notMemberTitle') }), _jsx(Text, { size: "sm", tone: "muted", className: "max-w-sm", children: translate('account.notMemberBody') })] }));
44
+ }
45
+ return _jsx(LoadedAccount, { projectId: projectId, self: self, refresh: async () => void (await query.refetch()) });
46
+ }
47
+ function LoadedAccount({ projectId, self, refresh, }) {
48
+ const source = useSelfAccountViewSource({ projectId, self, refresh });
49
+ return (_jsxs("div", { className: "flex h-full flex-col overflow-y-auto", children: [_jsx("div", { className: "flex min-h-[28rem] flex-col", children: _jsx(PortableDetailEditor, { source: source, projectId: projectId, documentId: self.id }) }), _jsx("div", { className: "mx-auto w-full max-w-2xl px-4 pb-8 sm:px-6", children: _jsx(CredentialPanel, {}) })] }));
50
+ }
51
+ function CenteredNote({ children, testId }) {
52
+ return (_jsx(Stack, { className: "h-full place-items-center p-8", align: "center", justify: "center", children: _jsx(Text, { size: "sm", tone: "muted", ...(testId ? { 'data-testid': testId } : {}), children: children }) }));
53
+ }
54
+ //# sourceMappingURL=account-settings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-settings.js","sourceRoot":"","sources":["../../../src/components/account/account-settings.tsx"],"names":[],"mappings":"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","sourcesContent":["import { UserCog } from 'lucide-react'\nimport type { UserRecord } from '@elytracms/core/persistence'\nimport { Heading, Stack, Text } from '@elytracms/studio/ui'\nimport { listStoreState, useOperationQuery } from '@elytracms/studio/lib/query'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { useAuth } from '../auth-context'\nimport { PortableDetailEditor } from '../cms/detail-editor'\nimport { CredentialPanel } from './credential-panel'\nimport { useSelfAccountViewSource } from './self-account-view-source'\n\n/**\n * The **account-settings** surface (EC-240 H-tail items 1–4). A member — admin or\n * not — opens their OWN record here (via the user menu → `users/<selfId>`). It is\n * self-permitted: the self record is read through `users.getSelf` (never the\n * admin-only `users.list`/`get`), so a non-admin reaches ONLY their own account.\n *\n * Reuses the portable edit shell (AD-19) for the identity form (name/avatar\n * editable → `updateProfile`; email/role read-only) and appends the credential\n * seam (change password). No bespoke form — the shell + the self source carry it.\n */\nexport function AccountSettings({ projectId }: { projectId: string }) {\n const auth = useAuth()\n const userId = auth.session?.user?.id ?? null\n\n const query = useOperationQuery<UserRecord | null>({\n queryKey: ['account', projectId, userId ?? '∅'],\n loader: async (operations) => {\n const result = await operations.users.getSelf({ projectId })\n if (!result.ok) throw new Error(result.error.message)\n return result.value ?? null\n },\n enabled: userId !== null,\n })\n const { status, errorMessage } = listStoreState(query)\n const self = query.data ?? null\n\n if (status === 'loading') {\n return <CenteredNote>{translate('account.loading')}</CenteredNote>\n }\n if (status === 'error') {\n return (\n <CenteredNote testId=\"account-error\">\n {errorMessage ?? translate('account.loadFailed')}\n </CenteredNote>\n )\n }\n if (!self) {\n // Authenticated but not a member (no role yet) — honest, not a raw auth error.\n return (\n <Stack gap=\"2\" align=\"center\" justify=\"center\" className=\"h-full p-8 text-center\">\n <UserCog className=\"size-6 text-muted-foreground\" />\n <Heading level={2} size=\"h3\">\n {translate('account.notMemberTitle')}\n </Heading>\n <Text size=\"sm\" tone=\"muted\" className=\"max-w-sm\">\n {translate('account.notMemberBody')}\n </Text>\n </Stack>\n )\n }\n\n return <LoadedAccount projectId={projectId} self={self} refresh={async () => void (await query.refetch())} />\n}\n\nfunction LoadedAccount({\n projectId,\n self,\n refresh,\n}: {\n projectId: string\n self: UserRecord\n refresh: () => Promise<void>\n}) {\n const source = useSelfAccountViewSource({ projectId, self, refresh })\n return (\n <div className=\"flex h-full flex-col overflow-y-auto\">\n {/* The identity form via the portable edit shell (name/avatar editable). */}\n <div className=\"flex min-h-[28rem] flex-col\">\n <PortableDetailEditor source={source} projectId={projectId} documentId={self.id} />\n </div>\n {/* The credential seam — part of the property surface, below the identity form. */}\n <div className=\"mx-auto w-full max-w-2xl px-4 pb-8 sm:px-6\">\n <CredentialPanel />\n </div>\n </div>\n )\n}\n\nfunction CenteredNote({ children, testId }: { children: React.ReactNode; testId?: string }) {\n return (\n <Stack className=\"h-full place-items-center p-8\" align=\"center\" justify=\"center\">\n <Text size=\"sm\" tone=\"muted\" {...(testId ? { 'data-testid': testId } : {})}>\n {children}\n </Text>\n </Stack>\n )\n}\n"]}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The credential seam (EC-240 H-tail item 3) — part of the account **property
3
+ * surface**, never a modal. Two blocks:
4
+ *
5
+ * - **Sign-in method** (read-only): today every studio account is email + password.
6
+ * OAuth "connected accounts" is a deferred extension.
7
+ * - **Change password**: current + new + confirm, handed straight to
8
+ * `AuthAdapter.changePassword` (provider-delegated). SEAM ONLY — the raw values
9
+ * are never logged, stored, or inspected here; they leave in the adapter call.
10
+ *
11
+ * The current-password field is the gate around this sensitive action (no separate
12
+ * confirm dialog needed).
13
+ */
14
+ export declare function CredentialPanel(): import("react").JSX.Element;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { KeyRound, ShieldCheck } from 'lucide-react';
4
+ import { Button, Card, CardContent, Field, Heading, Input, Row, Stack, Text, toast, } from '@elytracms/studio/ui';
5
+ import { translate } from '@elytracms/studio/lib/i18n';
6
+ import { useAuth } from '../auth-context';
7
+ /**
8
+ * The credential seam (EC-240 H-tail item 3) — part of the account **property
9
+ * surface**, never a modal. Two blocks:
10
+ *
11
+ * - **Sign-in method** (read-only): today every studio account is email + password.
12
+ * OAuth "connected accounts" is a deferred extension.
13
+ * - **Change password**: current + new + confirm, handed straight to
14
+ * `AuthAdapter.changePassword` (provider-delegated). SEAM ONLY — the raw values
15
+ * are never logged, stored, or inspected here; they leave in the adapter call.
16
+ *
17
+ * The current-password field is the gate around this sensitive action (no separate
18
+ * confirm dialog needed).
19
+ */
20
+ export function CredentialPanel() {
21
+ const auth = useAuth();
22
+ const [current, setCurrent] = useState('');
23
+ const [next, setNext] = useState('');
24
+ const [confirm, setConfirm] = useState('');
25
+ const [phase, setPhase] = useState('idle');
26
+ const [error, setError] = useState(null);
27
+ async function handleSubmit(event) {
28
+ event.preventDefault();
29
+ setError(null);
30
+ if (next !== confirm) {
31
+ setError(translate('account.password.mismatch'));
32
+ return;
33
+ }
34
+ setPhase('submitting');
35
+ // The raw values leave here ONLY into the provider adapter — never logged.
36
+ const result = await auth.changePassword({ currentPassword: current, newPassword: next });
37
+ setPhase('idle');
38
+ if (result.ok) {
39
+ setCurrent('');
40
+ setNext('');
41
+ setConfirm('');
42
+ toast.success(translate('account.password.changed'));
43
+ return;
44
+ }
45
+ setError(result.message);
46
+ }
47
+ const submitting = phase === 'submitting';
48
+ return (_jsx(Card, { "data-testid": "credential-panel", children: _jsx(CardContent, { className: "pt-5", children: _jsxs(Stack, { gap: "4", children: [_jsxs(Stack, { gap: "1", children: [_jsxs(Row, { gap: "2", align: "center", children: [_jsx(ShieldCheck, { className: "size-4 text-muted-foreground" }), _jsx(Heading, { level: 3, size: "h4", children: translate('account.security.title') })] }), _jsx(Text, { size: "sm", tone: "muted", children: translate('account.security.method') })] }), _jsx("form", { onSubmit: handleSubmit, noValidate: true, children: _jsxs(Stack, { gap: "3", children: [_jsx(Field, { label: translate('account.password.current'), htmlFor: "account-current-password", required: true, children: _jsx(Input, { id: "account-current-password", type: "password", autoComplete: "current-password", value: current, onChange: (e) => setCurrent(e.target.value), disabled: submitting }) }), _jsx(Field, { label: translate('account.password.new'), htmlFor: "account-new-password", required: true, children: _jsx(Input, { id: "account-new-password", type: "password", autoComplete: "new-password", value: next, onChange: (e) => setNext(e.target.value), disabled: submitting }) }), _jsx(Field, { label: translate('account.password.confirm'), htmlFor: "account-confirm-password", required: true, error: error, children: _jsx(Input, { id: "account-confirm-password", type: "password", autoComplete: "new-password", value: confirm, onChange: (e) => setConfirm(e.target.value), disabled: submitting }) }), _jsx(Row, { children: _jsxs(Button, { type: "submit", variant: "outline", disabled: submitting || !current || !next || !confirm, children: [_jsx(KeyRound, {}), submitting
49
+ ? translate('account.password.changing')
50
+ : translate('account.password.change')] }) })] }) })] }) }) }));
51
+ }
52
+ //# sourceMappingURL=credential-panel.js.map