@stapel/workspaces-react 0.6.0 → 0.8.0

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 (105) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/api/generated/schema.d.ts +774 -7
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +104 -7
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/workspacesApi.d.ts +55 -1
  7. package/dist/api/workspacesApi.d.ts.map +1 -1
  8. package/dist/api/workspacesApi.js +23 -0
  9. package/dist/api/workspacesApi.js.map +1 -1
  10. package/dist/default/InviteAcceptPage.d.ts +28 -0
  11. package/dist/default/InviteAcceptPage.d.ts.map +1 -0
  12. package/dist/default/InviteAcceptPage.js +141 -0
  13. package/dist/default/InviteAcceptPage.js.map +1 -0
  14. package/dist/default/MembersManager.d.ts +4 -4
  15. package/dist/default/MembersManager.d.ts.map +1 -1
  16. package/dist/default/MembersManager.js +61 -56
  17. package/dist/default/MembersManager.js.map +1 -1
  18. package/dist/default/index.d.ts +2 -0
  19. package/dist/default/index.d.ts.map +1 -1
  20. package/dist/default/index.js +1 -0
  21. package/dist/default/index.js.map +1 -1
  22. package/dist/flows/inviteAcceptFlow.d.ts +155 -0
  23. package/dist/flows/inviteAcceptFlow.d.ts.map +1 -0
  24. package/dist/flows/inviteAcceptFlow.js +126 -0
  25. package/dist/flows/inviteAcceptFlow.js.map +1 -0
  26. package/dist/headless/Can.d.ts +40 -0
  27. package/dist/headless/Can.d.ts.map +1 -0
  28. package/dist/headless/Can.js +30 -0
  29. package/dist/headless/Can.js.map +1 -0
  30. package/dist/headless/InviteAcceptFlow.d.ts +44 -0
  31. package/dist/headless/InviteAcceptFlow.d.ts.map +1 -0
  32. package/dist/headless/InviteAcceptFlow.js +67 -0
  33. package/dist/headless/InviteAcceptFlow.js.map +1 -0
  34. package/dist/headless/RoleSelect.d.ts +41 -0
  35. package/dist/headless/RoleSelect.d.ts.map +1 -0
  36. package/dist/headless/RoleSelect.js +34 -0
  37. package/dist/headless/RoleSelect.js.map +1 -0
  38. package/dist/i18n/generated/errors.gen.d.ts +48 -0
  39. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  40. package/dist/i18n/generated/errors.gen.js +24 -0
  41. package/dist/i18n/generated/errors.gen.js.map +1 -1
  42. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  43. package/dist/i18n/generated/errors.ru.gen.js +8 -0
  44. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  45. package/dist/i18n/keys.d.ts +28 -0
  46. package/dist/i18n/keys.d.ts.map +1 -1
  47. package/dist/i18n/keys.js +64 -0
  48. package/dist/i18n/keys.js.map +1 -1
  49. package/dist/i18n/ru.d.ts.map +1 -1
  50. package/dist/i18n/ru.js +30 -0
  51. package/dist/i18n/ru.js.map +1 -1
  52. package/dist/index.d.ts +17 -4
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +10 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/model/capabilities.d.ts +21 -0
  57. package/dist/model/capabilities.d.ts.map +1 -0
  58. package/dist/model/capabilities.js +32 -0
  59. package/dist/model/capabilities.js.map +1 -0
  60. package/dist/model/emailMask.d.ts +22 -0
  61. package/dist/model/emailMask.d.ts.map +1 -0
  62. package/dist/model/emailMask.js +36 -0
  63. package/dist/model/emailMask.js.map +1 -0
  64. package/dist/model/mutations.d.ts +107 -3
  65. package/dist/model/mutations.d.ts.map +1 -1
  66. package/dist/model/mutations.js +190 -1
  67. package/dist/model/mutations.js.map +1 -1
  68. package/dist/model/queries.d.ts +108 -2
  69. package/dist/model/queries.d.ts.map +1 -1
  70. package/dist/model/queries.js +139 -1
  71. package/dist/model/queries.js.map +1 -1
  72. package/dist/model/queryKeys.d.ts +12 -1
  73. package/dist/model/queryKeys.d.ts.map +1 -1
  74. package/dist/model/queryKeys.js +25 -0
  75. package/dist/model/queryKeys.js.map +1 -1
  76. package/dist/model/stepUp.d.ts +25 -0
  77. package/dist/model/stepUp.d.ts.map +1 -0
  78. package/dist/model/stepUp.js +79 -0
  79. package/dist/model/stepUp.js.map +1 -0
  80. package/llms.txt +49 -15
  81. package/manifest.json +346 -3
  82. package/package.json +5 -5
  83. package/src/analytics/generated/events.json +1 -1
  84. package/src/api/generated/schema.ts +774 -7
  85. package/src/api/types.ts +113 -7
  86. package/src/api/workspacesApi.ts +137 -0
  87. package/src/default/InviteAcceptPage.tsx +373 -0
  88. package/src/default/MembersManager.tsx +155 -145
  89. package/src/default/index.ts +2 -0
  90. package/src/flows/inviteAcceptFlow.ts +289 -0
  91. package/src/headless/Can.tsx +49 -0
  92. package/src/headless/InviteAcceptFlow.tsx +105 -0
  93. package/src/headless/RoleSelect.tsx +58 -0
  94. package/src/i18n/generated/errors.gen.ts +24 -0
  95. package/src/i18n/generated/errors.json +62 -0
  96. package/src/i18n/generated/errors.ru.gen.ts +8 -0
  97. package/src/i18n/keys.ts +68 -0
  98. package/src/i18n/ru.ts +34 -0
  99. package/src/index.ts +54 -1
  100. package/src/model/capabilities.ts +37 -0
  101. package/src/model/emailMask.ts +37 -0
  102. package/src/model/mutations.ts +248 -4
  103. package/src/model/queries.ts +205 -2
  104. package/src/model/queryKeys.ts +45 -1
  105. package/src/model/stepUp.ts +96 -0
@@ -0,0 +1,49 @@
1
+ import type { ReactNode } from "react";
2
+ import { useCapabilities } from "../model/queries.js";
3
+
4
+ /** Render-prop bag for {@link Can}'s function-children variant. */
5
+ export interface CanBag {
6
+ /** The wildcard-aware verdict for `capability` in `workspaceId`. */
7
+ readonly allowed: boolean;
8
+ /** The underlying read is still in flight (verdict is `false` meanwhile —
9
+ * deny-by-default; render a skeleton off this if flicker matters). */
10
+ readonly isLoading: boolean;
11
+ }
12
+
13
+ /**
14
+ * Capability gate (org-program §A2) — the mandate model's UI affordance:
15
+ *
16
+ * ```tsx
17
+ * <Can capability="meetings.spotlight" workspaceId={wsId}>
18
+ * <SpotlightButton />
19
+ * </Can>
20
+ * // or the render-prop variant, when "disabled" beats "hidden":
21
+ * <Can capability="members.invite" workspaceId={wsId}>
22
+ * {({ allowed }) => <Button disabled={!allowed}>…</Button>}
23
+ * </Can>
24
+ * ```
25
+ *
26
+ * Reads the caller's `my_capabilities` from the workspace detail
27
+ * ({@link useCapabilities}) and matches with the backend's ported wildcard
28
+ * matcher (`*` / `prefix.*`). Deny-by-default: nothing renders (or
29
+ * `allowed: false`) while loading, on error, for a non-member, or against a
30
+ * pre-0.6 backend. UI convenience ONLY — the backend re-checks every
31
+ * capability on every operation; hiding a button is not access control.
32
+ */
33
+ export function Can(props: {
34
+ /** Namespaced capability string, e.g. `"meetings.spotlight"`. */
35
+ capability: string;
36
+ workspaceId: string;
37
+ /** Static children render when allowed; function children always render,
38
+ * receiving the {@link CanBag}. */
39
+ children: ReactNode | ((bag: CanBag) => ReactNode);
40
+ /** Rendered when the verdict is a definite "no" (static children only). */
41
+ fallback?: ReactNode;
42
+ }): ReactNode {
43
+ const caps = useCapabilities(props.workspaceId);
44
+ const allowed = caps.can(props.capability);
45
+ if (typeof props.children === "function") {
46
+ return props.children({ allowed, isLoading: caps.isLoading });
47
+ }
48
+ return allowed ? props.children : (props.fallback ?? null);
49
+ }
@@ -0,0 +1,105 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { useFlow } from "@stapel/core";
4
+ import { createInviteAcceptFlow } from "../flows/inviteAcceptFlow.js";
5
+ import type { InviteAcceptState } from "../flows/inviteAcceptFlow.js";
6
+ import type { Member } from "../api/types.js";
7
+ import { useWorkspacesAnalytics, useWorkspacesApi } from "../model/context.js";
8
+
9
+ /** Render-prop bag for {@link InviteAcceptFlow}. */
10
+ export interface InviteAcceptFlowBag {
11
+ readonly state: InviteAcceptState;
12
+ /** From `newUser`/`claimError`: mint the login grant. */
13
+ claim(): void;
14
+ /** From `loginRequired`/`wrongAccount`: a session now exists — re-route on
15
+ * its email. (Also fired automatically when the `sessionEmail` prop
16
+ * becomes non-null while parked in either step.) */
17
+ sessionEstablished(email: string): void;
18
+ /** From `grantIssued`: the host exchanged the grant at auth. */
19
+ grantExchanged(): void;
20
+ /** From `basicData`: the initial-setup slot finished. */
21
+ completeBasicData(): void;
22
+ accept(): void;
23
+ decline(): void;
24
+ /** Re-fetch the preview and re-route (e.g. after a logout on wrongAccount). */
25
+ reload(): void;
26
+ }
27
+
28
+ /**
29
+ * Headless `/invite/{token}` journey (org-program §B4) — the flow machine
30
+ * (`flows/inviteAcceptFlow.ts`, see ITS doc for the full state graph and the
31
+ * grant_token hand-off seam) wired to this pair's runtime. Renderless: every
32
+ * screen (accept modal, wrong-account, the login and initial-setup SLOTS) is
33
+ * the host's; the default `InviteAcceptPage` is the reference skin.
34
+ *
35
+ * The pair knows nothing about auth: pass the signed-in email (or null) as
36
+ * `sessionEmail`, and connect `onLoginGrant` to auth-react's
37
+ * `exchangeLoginGrant` yourself — then call `grantExchanged()`.
38
+ */
39
+ export function InviteAcceptFlow(props: {
40
+ /** The invite token from the URL — a bearer secret, never log it. */
41
+ token: string;
42
+ /** The signed-in account's email, or null/undefined when no session. */
43
+ sessionEmail?: string | null;
44
+ children: (bag: InviteAcceptFlowBag) => ReactNode;
45
+ /** The grant_token hand-off (a credential — exchange, never log). */
46
+ onLoginGrant?: (grantToken: string) => void;
47
+ onAccepted?: (member: Member) => void;
48
+ onDeclined?: () => void;
49
+ }): ReactNode {
50
+ const api = useWorkspacesApi();
51
+ const analytics = useWorkspacesAnalytics();
52
+ const sessionEmail = props.sessionEmail ?? null;
53
+ const { token, onLoginGrant, onAccepted, onDeclined } = props;
54
+ const flow = useMemo(
55
+ () =>
56
+ createInviteAcceptFlow({
57
+ api,
58
+ analytics,
59
+ token,
60
+ ...(onLoginGrant !== undefined ? { onLoginGrant } : {}),
61
+ ...(onAccepted !== undefined ? { onAccepted } : {}),
62
+ ...(onDeclined !== undefined ? { onDeclined } : {}),
63
+ }),
64
+ [api, analytics, token, onLoginGrant, onAccepted, onDeclined]
65
+ );
66
+ const state = useFlow(flow.machine);
67
+
68
+ // Initial load — once per flow instance (a new token remounts the memo).
69
+ useEffect(() => {
70
+ if (flow.machine.getState().step === "idle") {
71
+ void flow.load(sessionEmail);
72
+ }
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- sessionEmail is read once at load time by design; later session changes re-route via the dedicated effect below, not a re-load
74
+ }, [flow]);
75
+
76
+ // Auto hand-off: the host's login slot signed the user in while we were
77
+ // parked in `loginRequired`/`wrongAccount` — re-route without requiring the
78
+ // host to call `sessionEstablished` manually.
79
+ useEffect(() => {
80
+ if (sessionEmail === null || sessionEmail === "") return;
81
+ const step = flow.machine.getState().step;
82
+ if (step === "loginRequired" || step === "wrongAccount") {
83
+ flow.sessionEstablished(sessionEmail);
84
+ }
85
+ }, [flow, sessionEmail, state.step]);
86
+
87
+ return props.children({
88
+ state,
89
+ claim: () => {
90
+ void flow.claim();
91
+ },
92
+ sessionEstablished: flow.sessionEstablished,
93
+ grantExchanged: flow.grantExchanged,
94
+ completeBasicData: flow.completeBasicData,
95
+ accept: () => {
96
+ void flow.accept();
97
+ },
98
+ decline: () => {
99
+ void flow.decline();
100
+ },
101
+ reload: () => {
102
+ void flow.load(sessionEmail);
103
+ },
104
+ });
105
+ }
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from "react";
2
+ import { useI18n } from "@stapel/core";
3
+ import type { StapelApiError } from "@stapel/core";
4
+ import type { RoleInfo } from "../api/types.js";
5
+ import { useRoles } from "../model/queries.js";
6
+
7
+ /** Render-prop bag for {@link RoleSelect}. */
8
+ export interface RoleSelectBag {
9
+ /** The effective registry (GET /roles): builtin four + the deployment's
10
+ * overlay, rank-descending. Empty while loading. */
11
+ readonly roles: readonly RoleInfo[];
12
+ /**
13
+ * Display label for a role key: the i18n key `workspaces.role.<key>` when
14
+ * the current locale's merged bundle carries it (the pair ships the builtin
15
+ * four; a client bundle merges its own — e.g. `workspaces.role.secretary`),
16
+ * else the RAW role key. The raw-name fallback is deliberate: a
17
+ * deployment-defined role without a translation must still be pickable,
18
+ * never render as a dotted i18n key.
19
+ */
20
+ labelFor(role: string): string;
21
+ readonly isLoading: boolean;
22
+ readonly isError: boolean;
23
+ readonly error: StapelApiError | null;
24
+ }
25
+
26
+ /**
27
+ * Headless role picker over the effective role registry (org-program §A2) —
28
+ * the component that lets role UI stop hardcoding the builtin four. Bring
29
+ * your own `<Select>`:
30
+ *
31
+ * ```tsx
32
+ * <RoleSelect>
33
+ * {({ roles, labelFor }) => (
34
+ * <Select options={roles.map((r) => ({ value: r.role, label: labelFor(r.role) }))} />
35
+ * )}
36
+ * </RoleSelect>
37
+ * ```
38
+ *
39
+ * `owner` is in the registry too — filter it out where "only an owner grants
40
+ * owner" applies (the backend enforces regardless).
41
+ */
42
+ export function RoleSelect(props: {
43
+ children: (bag: RoleSelectBag) => ReactNode;
44
+ }): ReactNode {
45
+ const query = useRoles();
46
+ const i18n = useI18n();
47
+ // Raw bundle lookup, NOT `t()`: `t` falls back to the key itself, which is
48
+ // exactly the wrong fallback here (a deployment role without a translation
49
+ // must show its raw name, not `workspaces.role.secretary`).
50
+ const bundle = i18n.getBundle();
51
+ return props.children({
52
+ roles: query.data ?? [],
53
+ labelFor: (role) => bundle[`workspaces.role.${role}`] ?? role,
54
+ isLoading: query.isLoading,
55
+ isError: query.isError,
56
+ error: query.error ?? null,
57
+ });
58
+ }
@@ -52,8 +52,10 @@ export const WORKSPACES_ERRORS = {
52
52
  "error.400.field.required": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} is required" },
53
53
  "error.400.field.unique": { status: 400, params: ["field"], remediation: "fix_input", en: "{field} must be unique" },
54
54
  "error.400.invalid_ad_id": { status: 400, params: [], remediation: "fix_input", en: "Invalid advertisement ID" },
55
+ "error.400.invalid_provision_username": { status: 400, params: [], remediation: "fix_input", en: "Invalid username for a provisioned account" },
55
56
  "error.400.invalid_role": { status: 400, params: [], remediation: "fix_input", en: "Invalid role" },
56
57
  "error.400.invitation_already_used": { status: 400, params: [], remediation: "fix_input", en: "Invitation has already been used" },
58
+ "error.400.invitation_declined": { status: 400, params: [], remediation: "contact_support", en: "Invitation has been declined" },
57
59
  "error.400.invitation_expired": { status: 400, params: [], remediation: "contact_support", en: "Invitation has expired" },
58
60
  "error.400.invitation_revoked": { status: 400, params: [], remediation: "contact_support", en: "Invitation has been revoked" },
59
61
  "error.400.validation_error": { status: 400, params: [], remediation: "fix_input", en: "Validation error" },
@@ -61,10 +63,14 @@ export const WORKSPACES_ERRORS = {
61
63
  "error.400.verification_invalid_factor": { status: 400, params: [], remediation: "verify", en: "This verification factor is not available" },
62
64
  "error.400.workspace_slug_taken": { status: 400, params: [], remediation: "fix_input", en: "Workspace slug is already taken" },
63
65
  "error.401.unauthorized": { status: 401, params: [], remediation: "reauthenticate", en: "Authentication required" },
66
+ "error.402.entitlement_required": { status: 402, params: [], remediation: "fix_input", en: "The workspace owner's plan does not include this feature" },
67
+ "error.402.member_limit_reached": { status: 402, params: ["limit"], remediation: "fix_input", en: "The workspace member limit ({limit}) has been reached" },
64
68
  "error.402.payment_required": { status: 402, params: [], remediation: "retry", en: "Payment required" },
65
69
  "error.403.forbidden": { status: 403, params: [], remediation: "retry", en: "You do not have permission to perform this action" },
66
70
  "error.403.forbidden_workspace": { status: 403, params: [], remediation: "contact_support", en: "You do not have access to this workspace" },
67
71
  "error.403.last_owner_cannot_be_removed": { status: 403, params: [], remediation: "fix_input", en: "The last owner cannot be removed; transfer ownership first" },
72
+ "error.403.membership_suspended": { status: 403, params: ["reason"], remediation: "fix_input", en: "Your membership in this workspace is suspended ({reason})" },
73
+ "error.403.missing_capability": { status: 403, params: ["capability"], remediation: "contact_support", en: "Your role does not include the {capability} capability in this workspace" },
68
74
  "error.403.network_blocked": { status: 403, params: [], remediation: "contact_support", en: "Requests from this network are not allowed" },
69
75
  "error.403.verification_enrollment_required": { status: 403, params: [], remediation: "verify", en: "Verification factor enrollment required" },
70
76
  "error.403.verification_required": { status: 403, params: [], remediation: "verify", en: "Additional verification required" },
@@ -78,6 +84,7 @@ export const WORKSPACES_ERRORS = {
78
84
  "error.406.not_acceptable": { status: 406, params: [], remediation: "retry", en: "Not acceptable" },
79
85
  "error.408.request_timeout": { status: 408, params: [], remediation: "retry", en: "Request timeout" },
80
86
  "error.409.conflict": { status: 409, params: [], remediation: "fix_input", en: "Resource already exists" },
87
+ "error.409.email_already_registered": { status: 409, params: [], remediation: "reauthenticate", en: "An account with this email already exists — log in instead" },
81
88
  "error.410.gone": { status: 410, params: [], remediation: "retry", en: "Resource has been permanently removed" },
82
89
  "error.413.payload_too_large": { status: 413, params: [], remediation: "retry", en: "Request body is too large" },
83
90
  "error.415.unsupported_media_type": { status: 415, params: [], remediation: "retry", en: "Unsupported media type" },
@@ -87,6 +94,7 @@ export const WORKSPACES_ERRORS = {
87
94
  "error.429.rate_limit": { status: 429, params: ["retry_after_minutes"], remediation: "wait_and_retry", en: "Too many attempts. Try again in {retry_after_minutes} minutes." },
88
95
  "error.429.too_many_requests": { status: 429, params: [], remediation: "wait_and_retry", en: "Too many requests. Please try again later." },
89
96
  "error.500.internal": { status: 500, params: [], remediation: "contact_support", en: "Something went wrong" },
97
+ "error.503.auth_unavailable": { status: 503, params: [], remediation: "wait_and_retry", en: "The authentication service is unavailable; try again later" },
90
98
  } as const;
91
99
 
92
100
  export type WorkspacesErrorCode = keyof typeof WORKSPACES_ERRORS;
@@ -110,8 +118,10 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
110
118
  "error.400.field.required",
111
119
  "error.400.field.unique",
112
120
  "error.400.invalid_ad_id",
121
+ "error.400.invalid_provision_username",
113
122
  "error.400.invalid_role",
114
123
  "error.400.invitation_already_used",
124
+ "error.400.invitation_declined",
115
125
  "error.400.invitation_expired",
116
126
  "error.400.invitation_revoked",
117
127
  "error.400.validation_error",
@@ -119,10 +129,14 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
119
129
  "error.400.verification_invalid_factor",
120
130
  "error.400.workspace_slug_taken",
121
131
  "error.401.unauthorized",
132
+ "error.402.entitlement_required",
133
+ "error.402.member_limit_reached",
122
134
  "error.402.payment_required",
123
135
  "error.403.forbidden",
124
136
  "error.403.forbidden_workspace",
125
137
  "error.403.last_owner_cannot_be_removed",
138
+ "error.403.membership_suspended",
139
+ "error.403.missing_capability",
126
140
  "error.403.network_blocked",
127
141
  "error.403.verification_enrollment_required",
128
142
  "error.403.verification_required",
@@ -136,6 +150,7 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
136
150
  "error.406.not_acceptable",
137
151
  "error.408.request_timeout",
138
152
  "error.409.conflict",
153
+ "error.409.email_already_registered",
139
154
  "error.410.gone",
140
155
  "error.413.payload_too_large",
141
156
  "error.415.unsupported_media_type",
@@ -145,6 +160,7 @@ export const WORKSPACES_ERROR_CODES: readonly WorkspacesErrorCode[] = [
145
160
  "error.429.rate_limit",
146
161
  "error.429.too_many_requests",
147
162
  "error.500.internal",
163
+ "error.503.auth_unavailable",
148
164
  ];
149
165
 
150
166
  /**
@@ -170,8 +186,10 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
170
186
  "error.400.field.required": "{field} is required",
171
187
  "error.400.field.unique": "{field} must be unique",
172
188
  "error.400.invalid_ad_id": "Invalid advertisement ID",
189
+ "error.400.invalid_provision_username": "Invalid username for a provisioned account",
173
190
  "error.400.invalid_role": "Invalid role",
174
191
  "error.400.invitation_already_used": "Invitation has already been used",
192
+ "error.400.invitation_declined": "Invitation has been declined",
175
193
  "error.400.invitation_expired": "Invitation has expired",
176
194
  "error.400.invitation_revoked": "Invitation has been revoked",
177
195
  "error.400.validation_error": "Validation error",
@@ -179,10 +197,14 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
179
197
  "error.400.verification_invalid_factor": "This verification factor is not available",
180
198
  "error.400.workspace_slug_taken": "Workspace slug is already taken",
181
199
  "error.401.unauthorized": "Authentication required",
200
+ "error.402.entitlement_required": "The workspace owner's plan does not include this feature",
201
+ "error.402.member_limit_reached": "The workspace member limit ({limit}) has been reached",
182
202
  "error.402.payment_required": "Payment required",
183
203
  "error.403.forbidden": "You do not have permission to perform this action",
184
204
  "error.403.forbidden_workspace": "You do not have access to this workspace",
185
205
  "error.403.last_owner_cannot_be_removed": "The last owner cannot be removed; transfer ownership first",
206
+ "error.403.membership_suspended": "Your membership in this workspace is suspended ({reason})",
207
+ "error.403.missing_capability": "Your role does not include the {capability} capability in this workspace",
186
208
  "error.403.network_blocked": "Requests from this network are not allowed",
187
209
  "error.403.verification_enrollment_required": "Verification factor enrollment required",
188
210
  "error.403.verification_required": "Additional verification required",
@@ -196,6 +218,7 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
196
218
  "error.406.not_acceptable": "Not acceptable",
197
219
  "error.408.request_timeout": "Request timeout",
198
220
  "error.409.conflict": "Resource already exists",
221
+ "error.409.email_already_registered": "An account with this email already exists — log in instead",
199
222
  "error.410.gone": "Resource has been permanently removed",
200
223
  "error.413.payload_too_large": "Request body is too large",
201
224
  "error.415.unsupported_media_type": "Unsupported media type",
@@ -205,4 +228,5 @@ export const workspacesErrorBundleEn: Record<WorkspacesErrorCode, string> = {
205
228
  "error.429.rate_limit": "Too many attempts. Try again in {retry_after_minutes} minutes.",
206
229
  "error.429.too_many_requests": "Too many requests. Please try again later.",
207
230
  "error.500.internal": "Something went wrong",
231
+ "error.503.auth_unavailable": "The authentication service is unavailable; try again later",
208
232
  };
@@ -144,6 +144,13 @@
144
144
  "remediation": "fix_input",
145
145
  "en": "Invalid advertisement ID"
146
146
  },
147
+ {
148
+ "code": "error.400.invalid_provision_username",
149
+ "status": 400,
150
+ "params": [],
151
+ "remediation": "fix_input",
152
+ "en": "Invalid username for a provisioned account"
153
+ },
147
154
  {
148
155
  "code": "error.400.invalid_role",
149
156
  "status": 400,
@@ -158,6 +165,13 @@
158
165
  "remediation": "fix_input",
159
166
  "en": "Invitation has already been used"
160
167
  },
168
+ {
169
+ "code": "error.400.invitation_declined",
170
+ "status": 400,
171
+ "params": [],
172
+ "remediation": "contact_support",
173
+ "en": "Invitation has been declined"
174
+ },
161
175
  {
162
176
  "code": "error.400.invitation_expired",
163
177
  "status": 400,
@@ -207,6 +221,22 @@
207
221
  "remediation": "reauthenticate",
208
222
  "en": "Authentication required"
209
223
  },
224
+ {
225
+ "code": "error.402.entitlement_required",
226
+ "status": 402,
227
+ "params": [],
228
+ "remediation": "fix_input",
229
+ "en": "The workspace owner's plan does not include this feature"
230
+ },
231
+ {
232
+ "code": "error.402.member_limit_reached",
233
+ "status": 402,
234
+ "params": [
235
+ "limit"
236
+ ],
237
+ "remediation": "fix_input",
238
+ "en": "The workspace member limit ({limit}) has been reached"
239
+ },
210
240
  {
211
241
  "code": "error.402.payment_required",
212
242
  "status": 402,
@@ -235,6 +265,24 @@
235
265
  "remediation": "fix_input",
236
266
  "en": "The last owner cannot be removed; transfer ownership first"
237
267
  },
268
+ {
269
+ "code": "error.403.membership_suspended",
270
+ "status": 403,
271
+ "params": [
272
+ "reason"
273
+ ],
274
+ "remediation": "fix_input",
275
+ "en": "Your membership in this workspace is suspended ({reason})"
276
+ },
277
+ {
278
+ "code": "error.403.missing_capability",
279
+ "status": 403,
280
+ "params": [
281
+ "capability"
282
+ ],
283
+ "remediation": "contact_support",
284
+ "en": "Your role does not include the {capability} capability in this workspace"
285
+ },
238
286
  {
239
287
  "code": "error.403.network_blocked",
240
288
  "status": 403,
@@ -326,6 +374,13 @@
326
374
  "remediation": "fix_input",
327
375
  "en": "Resource already exists"
328
376
  },
377
+ {
378
+ "code": "error.409.email_already_registered",
379
+ "status": 409,
380
+ "params": [],
381
+ "remediation": "reauthenticate",
382
+ "en": "An account with this email already exists — log in instead"
383
+ },
329
384
  {
330
385
  "code": "error.410.gone",
331
386
  "status": 410,
@@ -390,5 +445,12 @@
390
445
  "params": [],
391
446
  "remediation": "contact_support",
392
447
  "en": "Something went wrong"
448
+ },
449
+ {
450
+ "code": "error.503.auth_unavailable",
451
+ "status": 503,
452
+ "params": [],
453
+ "remediation": "wait_and_retry",
454
+ "en": "The authentication service is unavailable; try again later"
393
455
  }
394
456
  ]
@@ -29,8 +29,10 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
29
29
  "error.400.field.required": "Поле «{field}» обязательно",
30
30
  "error.400.field.unique": "Значение поля «{field}» должно быть уникальным",
31
31
  "error.400.invalid_ad_id": "Недопустимый идентификатор объявления",
32
+ "error.400.invalid_provision_username": "Недопустимое имя пользователя для создаваемого аккаунта",
32
33
  "error.400.invalid_role": "Недопустимая роль",
33
34
  "error.400.invitation_already_used": "Приглашение уже было использовано",
35
+ "error.400.invitation_declined": "Приглашение было отклонено",
34
36
  "error.400.invitation_expired": "Срок действия приглашения истёк",
35
37
  "error.400.invitation_revoked": "Приглашение было отозвано",
36
38
  "error.400.validation_error": "Ошибка валидации",
@@ -38,10 +40,14 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
38
40
  "error.400.verification_invalid_factor": "Этот способ подтверждения недоступен",
39
41
  "error.400.workspace_slug_taken": "Slug рабочего пространства уже занят",
40
42
  "error.401.unauthorized": "Требуется аутентификация",
43
+ "error.402.entitlement_required": "Тариф владельца рабочего пространства не включает эту возможность",
44
+ "error.402.member_limit_reached": "Достигнут лимит участников рабочего пространства ({limit})",
41
45
  "error.402.payment_required": "Требуется оплата",
42
46
  "error.403.forbidden": "У вас нет прав для выполнения этого действия",
43
47
  "error.403.forbidden_workspace": "У вас нет доступа к этому рабочему пространству",
44
48
  "error.403.last_owner_cannot_be_removed": "Нельзя удалить последнего владельца; сначала передайте права владения",
49
+ "error.403.membership_suspended": "Ваше членство в этом рабочем пространстве приостановлено ({reason})",
50
+ "error.403.missing_capability": "Ваша роль не включает право {capability} в этом рабочем пространстве",
45
51
  "error.403.network_blocked": "Запросы из этой сети не разрешены.",
46
52
  "error.403.verification_enrollment_required": "Требуется регистрация фактора подтверждения.",
47
53
  "error.403.verification_required": "Требуется дополнительная проверка",
@@ -55,6 +61,7 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
55
61
  "error.406.not_acceptable": "Недопустимый формат ответа",
56
62
  "error.408.request_timeout": "Время ожидания запроса истекло",
57
63
  "error.409.conflict": "Ресурс уже существует",
64
+ "error.409.email_already_registered": "Аккаунт с этим email уже существует — войдите в него",
58
65
  "error.410.gone": "Ресурс был безвозвратно удалён",
59
66
  "error.413.payload_too_large": "Тело запроса слишком большое",
60
67
  "error.415.unsupported_media_type": "Неподдерживаемый тип данных",
@@ -64,4 +71,5 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
64
71
  "error.429.rate_limit": "Слишком много попыток. Повторите попытку через {retry_after_minutes} мин.",
65
72
  "error.429.too_many_requests": "Слишком много запросов. Пожалуйста, повторите попытку позже.",
66
73
  "error.500.internal": "Что-то пошло не так",
74
+ "error.503.auth_unavailable": "Сервис аутентификации недоступен; повторите попытку позже",
67
75
  };
package/src/i18n/keys.ts CHANGED
@@ -50,6 +50,40 @@ export const WORKSPACES_I18N_KEYS = {
50
50
  roleAdmin: "workspaces.members.role.admin",
51
51
  roleMember: "workspaces.members.role.member",
52
52
  roleViewer: "workspaces.members.role.viewer",
53
+ // Role registry labels (org-program §A2) — the `workspaces.role.<key>`
54
+ // namespace RoleSelect resolves labels in: the pair ships the builtin four;
55
+ // a client bundle merges its own (`workspaces.role.secretary`, …) and
56
+ // RoleSelect falls back to the RAW role key when a label is missing.
57
+ roleLabelOwner: "workspaces.role.owner",
58
+ roleLabelAdmin: "workspaces.role.admin",
59
+ roleLabelMember: "workspaces.role.member",
60
+ roleLabelViewer: "workspaces.role.viewer",
61
+ // Invite accept flow (org-program §B4 — InviteAcceptFlow headless +
62
+ // InviteAcceptPage default skin), one key per flow screen.
63
+ inviteLoading: "workspaces.invite.loading",
64
+ inviteAcceptTitle: "workspaces.invite.acceptTitle",
65
+ inviteRoleLine: "workspaces.invite.roleLine",
66
+ inviteEmailLine: "workspaces.invite.emailLine",
67
+ inviteJoinCta: "workspaces.invite.joinCta",
68
+ inviteDeclineCta: "workspaces.invite.declineCta",
69
+ inviteAccepted: "workspaces.invite.accepted",
70
+ inviteDeclined: "workspaces.invite.declined",
71
+ inviteUnavailableExpired: "workspaces.invite.unavailable.expired",
72
+ inviteUnavailableRevoked: "workspaces.invite.unavailable.revoked",
73
+ inviteUnavailableAccepted: "workspaces.invite.unavailable.accepted",
74
+ inviteUnavailableDeclined: "workspaces.invite.unavailable.declined",
75
+ inviteWrongAccount: "workspaces.invite.wrongAccount",
76
+ inviteWrongAccountHint: "workspaces.invite.wrongAccountHint",
77
+ inviteSwitchAccountCta: "workspaces.invite.switchAccountCta",
78
+ inviteLoginTitle: "workspaces.invite.loginTitle",
79
+ inviteNewUserHint: "workspaces.invite.newUserHint",
80
+ inviteCreateAccountCta: "workspaces.invite.createAccountCta",
81
+ inviteClaiming: "workspaces.invite.claiming",
82
+ inviteExchanging: "workspaces.invite.exchanging",
83
+ inviteExchangeFailed: "workspaces.invite.exchangeFailed",
84
+ inviteRetryCta: "workspaces.invite.retryCta",
85
+ inviteBasicDataTitle: "workspaces.invite.basicDataTitle",
86
+ inviteBasicDataContinueCta: "workspaces.invite.basicDataContinueCta",
53
87
  } as const;
54
88
 
55
89
  export type WorkspacesI18nKey =
@@ -101,6 +135,40 @@ export const workspacesI18nBundleEn: I18nDictionary = {
101
135
  "workspaces.members.role.admin": "Admin",
102
136
  "workspaces.members.role.member": "Member",
103
137
  "workspaces.members.role.viewer": "Viewer",
138
+
139
+ // Role registry labels (builtin four; clients merge their own roles)
140
+ "workspaces.role.owner": "Owner",
141
+ "workspaces.role.admin": "Admin",
142
+ "workspaces.role.member": "Member",
143
+ "workspaces.role.viewer": "Viewer",
144
+
145
+ // Invite accept flow (org-program §B4)
146
+ "workspaces.invite.loading": "Loading invitation…",
147
+ "workspaces.invite.acceptTitle": "Join {workspace}",
148
+ "workspaces.invite.roleLine": "You've been invited as {role}.",
149
+ "workspaces.invite.emailLine": "Invitation for {email}",
150
+ "workspaces.invite.joinCta": "Join workspace",
151
+ "workspaces.invite.declineCta": "Decline",
152
+ "workspaces.invite.accepted": "You've joined {workspace}.",
153
+ "workspaces.invite.declined": "Invitation declined.",
154
+ "workspaces.invite.unavailable.expired": "This invitation has expired. Ask for a new one.",
155
+ "workspaces.invite.unavailable.revoked": "This invitation was revoked.",
156
+ "workspaces.invite.unavailable.accepted": "This invitation has already been used.",
157
+ "workspaces.invite.unavailable.declined": "This invitation was declined.",
158
+ "workspaces.invite.wrongAccount": "This invitation is for a different account",
159
+ "workspaces.invite.wrongAccountHint":
160
+ "You're signed in as {email}, but the invitation was sent to {invited}. Switch accounts to continue.",
161
+ "workspaces.invite.switchAccountCta": "Switch account",
162
+ "workspaces.invite.loginTitle": "Sign in to accept the invitation",
163
+ "workspaces.invite.newUserHint":
164
+ "We'll create a verified account for {email} — no password or email confirmation needed.",
165
+ "workspaces.invite.createAccountCta": "Create account and continue",
166
+ "workspaces.invite.claiming": "Creating your account…",
167
+ "workspaces.invite.exchanging": "Signing you in…",
168
+ "workspaces.invite.exchangeFailed": "Couldn't finish signing you in.",
169
+ "workspaces.invite.retryCta": "Try again",
170
+ "workspaces.invite.basicDataTitle": "Set up your profile",
171
+ "workspaces.invite.basicDataContinueCta": "Continue",
104
172
  };
105
173
 
106
174
  /**
package/src/i18n/ru.ts CHANGED
@@ -58,6 +58,40 @@ export const workspacesI18nBundleRu: I18nDictionary = {
58
58
  "workspaces.members.role.admin": "Администратор",
59
59
  "workspaces.members.role.member": "Участник",
60
60
  "workspaces.members.role.viewer": "Наблюдатель",
61
+
62
+ // Role registry labels (builtin four; clients merge their own roles)
63
+ "workspaces.role.owner": "Владелец",
64
+ "workspaces.role.admin": "Администратор",
65
+ "workspaces.role.member": "Участник",
66
+ "workspaces.role.viewer": "Наблюдатель",
67
+
68
+ // Invite accept flow (org-program §B4)
69
+ "workspaces.invite.loading": "Загрузка приглашения…",
70
+ "workspaces.invite.acceptTitle": "Присоединиться к {workspace}",
71
+ "workspaces.invite.roleLine": "Вас пригласили с ролью {role}.",
72
+ "workspaces.invite.emailLine": "Приглашение для {email}",
73
+ "workspaces.invite.joinCta": "Присоединиться",
74
+ "workspaces.invite.declineCta": "Отклонить",
75
+ "workspaces.invite.accepted": "Вы присоединились к {workspace}.",
76
+ "workspaces.invite.declined": "Приглашение отклонено.",
77
+ "workspaces.invite.unavailable.expired": "Срок приглашения истёк. Попросите новое.",
78
+ "workspaces.invite.unavailable.revoked": "Приглашение было отозвано.",
79
+ "workspaces.invite.unavailable.accepted": "Приглашение уже использовано.",
80
+ "workspaces.invite.unavailable.declined": "Приглашение было отклонено.",
81
+ "workspaces.invite.wrongAccount": "Приглашение для другого аккаунта",
82
+ "workspaces.invite.wrongAccountHint":
83
+ "Вы вошли как {email}, а приглашение отправлено на {invited}. Смените аккаунт, чтобы продолжить.",
84
+ "workspaces.invite.switchAccountCta": "Сменить аккаунт",
85
+ "workspaces.invite.loginTitle": "Войдите, чтобы принять приглашение",
86
+ "workspaces.invite.newUserHint":
87
+ "Мы создадим подтверждённый аккаунт для {email} — без пароля и подтверждения почты.",
88
+ "workspaces.invite.createAccountCta": "Создать аккаунт и продолжить",
89
+ "workspaces.invite.claiming": "Создаём ваш аккаунт…",
90
+ "workspaces.invite.exchanging": "Выполняем вход…",
91
+ "workspaces.invite.exchangeFailed": "Не удалось завершить вход.",
92
+ "workspaces.invite.retryCta": "Попробовать ещё раз",
93
+ "workspaces.invite.basicDataTitle": "Настройте профиль",
94
+ "workspaces.invite.basicDataContinueCta": "Продолжить",
61
95
  };
62
96
 
63
97
  /**