@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.
- package/convex/auth.ts +61 -12
- package/convex/authority.ts +40 -2
- package/convex/functions.ts +9 -0
- package/convex/invites.ts +75 -0
- package/convex/members.ts +44 -18
- package/convex/schema.ts +37 -0
- package/convex/users.ts +89 -2
- package/dist/components/account/account-settings.d.ts +13 -0
- package/dist/components/account/account-settings.js +54 -0
- package/dist/components/account/account-settings.js.map +1 -0
- package/dist/components/account/credential-panel.d.ts +14 -0
- package/dist/components/account/credential-panel.js +52 -0
- package/dist/components/account/credential-panel.js.map +1 -0
- package/dist/components/account/self-account-schema.d.ts +19 -0
- package/dist/components/account/self-account-schema.js +52 -0
- package/dist/components/account/self-account-schema.js.map +1 -0
- package/dist/components/account/self-account-view-source.d.ts +35 -0
- package/dist/components/account/self-account-view-source.js +110 -0
- package/dist/components/account/self-account-view-source.js.map +1 -0
- package/dist/components/auth-context.d.ts +5 -1
- package/dist/components/auth-context.js +9 -0
- package/dist/components/auth-context.js.map +1 -1
- package/dist/components/current-user-menu.js +11 -2
- package/dist/components/current-user-menu.js.map +1 -1
- package/dist/components/deployment-setup-gate.d.ts +31 -0
- package/dist/components/deployment-setup-gate.js +92 -0
- package/dist/components/deployment-setup-gate.js.map +1 -0
- package/dist/components/project-session.d.ts +1 -1
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/use-hydration-phase.js +1 -1
- package/dist/components/use-hydration-phase.js.map +1 -1
- package/dist/components/users/invite-user-bar.d.ts +3 -0
- package/dist/components/users/invite-user-bar.js +91 -0
- package/dist/components/users/invite-user-bar.js.map +1 -0
- package/dist/components/users/users-module.js +13 -2
- package/dist/components/users/users-module.js.map +1 -1
- package/dist/lib/auth/auth-adapter.d.ts +50 -2
- package/dist/lib/auth/auth-adapter.js.map +1 -1
- package/dist/lib/auth/convex-auth-adapter.d.ts +28 -1
- package/dist/lib/auth/convex-auth-adapter.js +113 -1
- package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
- package/dist/lib/auth/dev-auto-login-adapter.d.ts +3 -1
- package/dist/lib/auth/dev-auto-login-adapter.js +6 -0
- package/dist/lib/auth/dev-auto-login-adapter.js.map +1 -1
- package/dist/lib/auth/index.d.ts +2 -2
- package/dist/lib/auth/index.js +1 -1
- package/dist/lib/auth/index.js.map +1 -1
- package/dist/lib/auth/local-auth-adapter.d.ts +3 -1
- package/dist/lib/auth/local-auth-adapter.js +16 -0
- package/dist/lib/auth/local-auth-adapter.js.map +1 -1
- package/dist/lib/i18n/messages.en.js +45 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/studio-operations.js +1 -1
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/backend-mode.d.ts +1 -1
- package/dist/lib/persistence/backend-mode.js +1 -1
- package/dist/lib/persistence/backend-mode.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +2 -1
- package/dist/lib/persistence/convex-adapter.js +69 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/deployment-readiness.d.ts +36 -0
- package/dist/lib/persistence/deployment-readiness.js +26 -0
- package/dist/lib/persistence/deployment-readiness.js.map +1 -0
- package/dist/lib/persistence/index.d.ts +3 -1
- package/dist/lib/persistence/index.js +4 -1
- package/dist/lib/persistence/index.js.map +1 -1
- package/dist/lib/workspace/switcher-source.js +1 -1
- package/dist/lib/workspace/switcher-source.js.map +1 -1
- package/dist/lib/workspace/workspace-source.d.ts +1 -1
- package/dist/lib/workspace/workspace-source.js +1 -1
- package/dist/lib/workspace/workspace-source.js.map +1 -1
- 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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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
|
})
|
package/convex/authority.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { checkAuthorityFloor } from '@elytracms/core/operations'
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
+
}
|
package/convex/functions.ts
CHANGED
|
@@ -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
|
-
|
|
90
|
-
await
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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 {
|
|
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
|