@simple-auth-kit/auth-client 1.0.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.
- package/dist/auth-client.d.ts +196 -0
- package/dist/auth-client.js +389 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/types.d.ts +490 -0
- package/dist/types.js +13 -0
- package/package.json +19 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { AuditLogListFilter, AuditLogListResult, AuthTokens, CountryListFilter, CountryListResult, CountrySummary, CreateCountryInput, CreateCustomerInput, CreateLanguageInput, CreateRoleInput, CreateUserInput, CurrentUser, CustomerListFilter, CustomerListResult, CustomerSummary, DefinePermissionInput, LanguageListFilter, LanguageListResult, LanguageSummary, MemberRoles, PermissionSummary, RoleSummary, SelfProfile, SessionSummary, TokenStorage, TwoFactorChallenge, UpdateCountryInput, UpdateCustomerInput, UpdateLanguageInput, UpdateRoleInput, UpdateUserInput, UserListFilter, UserListResult, UserSummary, WorkspaceIdResolver, WorkspaceMember, WorkspaceScope, WorkspaceSummary } from "./types.js";
|
|
2
|
+
export interface AuthClientOptions {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
storage: TokenStorage;
|
|
5
|
+
/**
|
|
6
|
+
* The workspace that workspace-scoped calls act in.
|
|
7
|
+
*
|
|
8
|
+
* Leave it out entirely against the plain backend variant (`easy-auth add <combo>`): nothing
|
|
9
|
+
* then ever sends `X-Workspace-Id`, and no call site has to mention workspaces. Against the
|
|
10
|
+
* workspaces variant (`--workspaces`) set it once — a string for a fixed workspace, or a
|
|
11
|
+
* function resolved per request so the app's own store stays the owner of "which workspace am
|
|
12
|
+
* I in", exactly as `TokenStorage` keeps token persistence out of this package.
|
|
13
|
+
*/
|
|
14
|
+
workspaceId?: string | WorkspaceIdResolver;
|
|
15
|
+
}
|
|
16
|
+
/** The header a request uses to name the workspace it acts in. Lowercased — `fetch` header names are case-insensitive. */
|
|
17
|
+
export declare const WORKSPACE_HEADER = "x-workspace-id";
|
|
18
|
+
/**
|
|
19
|
+
* One client shared by all 4 apps (admin-nextjs, admin-react, mobile-expo, mobile-bare-rn) —
|
|
20
|
+
* the auth flow and API contract are identical, only `TokenStorage` differs per platform.
|
|
21
|
+
* Mirrors the deps-injection pattern used throughout registry/core: business logic here is
|
|
22
|
+
* storage-agnostic.
|
|
23
|
+
*
|
|
24
|
+
* It also serves both backend variants from one build. Workspaces are an install-time choice of
|
|
25
|
+
* the consuming project, not a runtime mode of this client: the workspace surface below
|
|
26
|
+
* (`createWorkspace`, `listWorkspaces`, `listWorkspaceMembers`, ...) only exists on the
|
|
27
|
+
* `--workspaces` backend, and a consumer on the plain variant simply never calls it and never
|
|
28
|
+
* configures `workspaceId` — in which case `X-Workspace-Id` is never sent on anything.
|
|
29
|
+
*
|
|
30
|
+
* Which calls carry the header is a structural property here, not a per-call decision:
|
|
31
|
+
* `request()` cannot pass a workspace at all, and only `scopedRequest()` can.
|
|
32
|
+
*/
|
|
33
|
+
export declare class AuthClient {
|
|
34
|
+
private readonly opts;
|
|
35
|
+
private activeWorkspace;
|
|
36
|
+
constructor(opts: AuthClientOptions);
|
|
37
|
+
/** Replaces the configured active workspace. `null` goes back to sending no workspace at all. */
|
|
38
|
+
setActiveWorkspace(workspaceId: string | WorkspaceIdResolver | null): void;
|
|
39
|
+
/** Resolves the active workspace — awaiting the resolver function if one was configured. */
|
|
40
|
+
getActiveWorkspace(): Promise<string | null>;
|
|
41
|
+
signup(input: {
|
|
42
|
+
email: string;
|
|
43
|
+
password: string;
|
|
44
|
+
}): Promise<AuthTokens>;
|
|
45
|
+
/** `identifier` is matched against email, username, and phone, in that order. Returns tokens directly, or a 2FA challenge to complete via `loginTwoFactor`. */
|
|
46
|
+
login(input: {
|
|
47
|
+
identifier: string;
|
|
48
|
+
password: string;
|
|
49
|
+
}): Promise<AuthTokens | TwoFactorChallenge>;
|
|
50
|
+
loginTwoFactor(input: {
|
|
51
|
+
challengeToken: string;
|
|
52
|
+
code: string;
|
|
53
|
+
}): Promise<AuthTokens>;
|
|
54
|
+
/** Rotates the refresh token; callers rarely need this directly — `request()` calls it automatically on a 401. */
|
|
55
|
+
refresh(): Promise<AuthTokens>;
|
|
56
|
+
logout(): Promise<void>;
|
|
57
|
+
logoutAll(): Promise<void>;
|
|
58
|
+
logoutOthers(): Promise<void>;
|
|
59
|
+
/** Requires the current password. Every other session is revoked; the one making this call is left alone. */
|
|
60
|
+
changePassword(input: {
|
|
61
|
+
currentPassword: string;
|
|
62
|
+
newPassword: string;
|
|
63
|
+
}): Promise<void>;
|
|
64
|
+
/** Self-service — no admin permission required, updates the caller's own row. Not workspace-scoped: a profile isn't a workspace concept. */
|
|
65
|
+
updateProfile(input: UpdateUserInput): Promise<SelfProfile>;
|
|
66
|
+
/**
|
|
67
|
+
* The one identity endpoint that answers differently per workspace: it reports the roles and
|
|
68
|
+
* permissions that apply to *this* request, so it carries the active workspace when there is
|
|
69
|
+
* one. On the workspaces variant it is the only way to learn your permissions in a workspace,
|
|
70
|
+
* which is what the client-side UI gating reads. Pass `{ workspaceId: null }` for the
|
|
71
|
+
* workspace-free identity, which answers with empty roles and permissions.
|
|
72
|
+
*/
|
|
73
|
+
me(scope?: WorkspaceScope): Promise<CurrentUser>;
|
|
74
|
+
sessions(): Promise<SessionSummary[]>;
|
|
75
|
+
isAuthenticated(): Promise<boolean>;
|
|
76
|
+
enrollTwoFactor(): Promise<{
|
|
77
|
+
secret: string;
|
|
78
|
+
provisioningUri: string;
|
|
79
|
+
}>;
|
|
80
|
+
confirmTwoFactor(code: string): Promise<{
|
|
81
|
+
backupCodes: string[];
|
|
82
|
+
}>;
|
|
83
|
+
disableTwoFactor(code: string): Promise<void>;
|
|
84
|
+
requestPasswordReset(input: {
|
|
85
|
+
email: string;
|
|
86
|
+
}): Promise<void>;
|
|
87
|
+
resetPassword(input: {
|
|
88
|
+
token: string;
|
|
89
|
+
newPassword: string;
|
|
90
|
+
}): Promise<void>;
|
|
91
|
+
/** Returns the provider URL to send the browser/webview to; the backend handles the round-trip and callback. */
|
|
92
|
+
oauthStart(provider: string): Promise<{
|
|
93
|
+
url: string;
|
|
94
|
+
}>;
|
|
95
|
+
/**
|
|
96
|
+
* Not workspace-scoped — you cannot already be in the workspace you are creating, so this
|
|
97
|
+
* deliberately sends no `X-Workspace-Id`. The creator becomes an `["admin","member"]` member.
|
|
98
|
+
*/
|
|
99
|
+
createWorkspace(name: string): Promise<WorkspaceSummary>;
|
|
100
|
+
/** Not workspace-scoped: any authenticated user, answering with the workspaces they belong to and their roles in each. */
|
|
101
|
+
listWorkspaces(): Promise<WorkspaceSummary[]>;
|
|
102
|
+
/** Any member of the workspace may list it. */
|
|
103
|
+
listWorkspaceMembers(scope?: WorkspaceScope): Promise<WorkspaceMember[]>;
|
|
104
|
+
/** [admin] Adds an existing user by email. `roles` defaults to `["member"]` on the backend. */
|
|
105
|
+
addWorkspaceMember(input: {
|
|
106
|
+
email: string;
|
|
107
|
+
roles?: string[];
|
|
108
|
+
}, scope?: WorkspaceScope): Promise<WorkspaceMember>;
|
|
109
|
+
/** [admin] Replaces a member's whole role set. The backend refuses (403) when the member is you. */
|
|
110
|
+
setWorkspaceMemberRoles(memberId: string, roles: string[], scope?: WorkspaceScope): Promise<MemberRoles>;
|
|
111
|
+
/** [admin] Removes a member, and their direct permission grants with them. The backend refuses (403) when the member is you. */
|
|
112
|
+
removeWorkspaceMember(memberId: string, scope?: WorkspaceScope): Promise<void>;
|
|
113
|
+
blockUser(userId: string, scope?: WorkspaceScope): Promise<void>;
|
|
114
|
+
unblockUser(userId: string, scope?: WorkspaceScope): Promise<void>;
|
|
115
|
+
/** A routine administrative on/off toggle — distinct from block/unblock, a security/moderation action. Both independently deny login. */
|
|
116
|
+
deactivateUser(userId: string, scope?: WorkspaceScope): Promise<void>;
|
|
117
|
+
activateUser(userId: string, scope?: WorkspaceScope): Promise<void>;
|
|
118
|
+
listUsers(filter?: UserListFilter, scope?: WorkspaceScope): Promise<UserListResult>;
|
|
119
|
+
/** No invitation email — the account is usable immediately with the password given here. */
|
|
120
|
+
createUser(input: CreateUserInput, scope?: WorkspaceScope): Promise<UserSummary>;
|
|
121
|
+
getUser(userId: string, scope?: WorkspaceScope): Promise<UserSummary>;
|
|
122
|
+
/** Profile fields only — email is the login identifier and is not editable here. */
|
|
123
|
+
updateUser(userId: string, input: UpdateUserInput, scope?: WorkspaceScope): Promise<UserSummary>;
|
|
124
|
+
/** Soft-delete: the account stops appearing in listings and can no longer authenticate. The backend refuses (403) when the target is you. */
|
|
125
|
+
deleteUser(userId: string, reason?: string, scope?: WorkspaceScope): Promise<void>;
|
|
126
|
+
/** `activeOnly` is what a role picker (Add user, Assign role) should pass — omit it for the Roles management page, which wants everything. */
|
|
127
|
+
listRoles(filter?: {
|
|
128
|
+
activeOnly?: boolean;
|
|
129
|
+
}, scope?: WorkspaceScope): Promise<RoleSummary[]>;
|
|
130
|
+
createRole(input: CreateRoleInput, scope?: WorkspaceScope): Promise<RoleSummary>;
|
|
131
|
+
updateRole(roleId: string, input: UpdateRoleInput, scope?: WorkspaceScope): Promise<RoleSummary>;
|
|
132
|
+
/** Soft-delete: existing assignments are left in place, and the role simply stops being resolved. */
|
|
133
|
+
deleteRole(roleId: string, reason?: string, scope?: WorkspaceScope): Promise<void>;
|
|
134
|
+
attachPermissionToRole(roleId: string, permission: string, scope?: WorkspaceScope): Promise<void>;
|
|
135
|
+
detachPermissionFromRole(roleId: string, permissionSlug: string, scope?: WorkspaceScope): Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* The catalog itself is global on both variants; what is workspace-scoped is which roles/
|
|
138
|
+
* memberships point at each row. Still sent through `scopedRequest`: on the workspaces variant
|
|
139
|
+
* every `/auth/admin/*` route sits behind the same guard that requires `X-Workspace-Id`, even
|
|
140
|
+
* this one. `activeOnly` is what a permission picker (Create/Edit Role) should pass — omit it
|
|
141
|
+
* for the Permissions management page, which wants everything.
|
|
142
|
+
*/
|
|
143
|
+
listPermissions(filter?: {
|
|
144
|
+
activeOnly?: boolean;
|
|
145
|
+
}, scope?: WorkspaceScope): Promise<PermissionSummary[]>;
|
|
146
|
+
/** Upserted on `slug` — creates a new capability, or edits/deactivates an existing one. */
|
|
147
|
+
definePermission(input: DefinePermissionInput, scope?: WorkspaceScope): Promise<PermissionSummary>;
|
|
148
|
+
assignRole(userId: string, role: string, scope?: WorkspaceScope): Promise<void>;
|
|
149
|
+
revokeRole(userId: string, roleName: string, scope?: WorkspaceScope): Promise<void>;
|
|
150
|
+
grantPermission(userId: string, permission: string, scope?: WorkspaceScope): Promise<void>;
|
|
151
|
+
revokePermission(userId: string, permissionKey: string, scope?: WorkspaceScope): Promise<void>;
|
|
152
|
+
listAuditLog(filter?: AuditLogListFilter, scope?: WorkspaceScope): Promise<AuditLogListResult>;
|
|
153
|
+
/** `activeOnly` is what a country picker/dropdown should pass — omit it for the Countries management page, which wants everything. */
|
|
154
|
+
listCountries(filter?: CountryListFilter, scope?: WorkspaceScope): Promise<CountryListResult>;
|
|
155
|
+
createCountry(input: CreateCountryInput, scope?: WorkspaceScope): Promise<CountrySummary>;
|
|
156
|
+
getCountry(countryId: string, scope?: WorkspaceScope): Promise<CountrySummary>;
|
|
157
|
+
updateCountry(countryId: string, input: UpdateCountryInput, scope?: WorkspaceScope): Promise<CountrySummary>;
|
|
158
|
+
/** Soft-delete: the row stops appearing in listings but survives for audit purposes. */
|
|
159
|
+
deleteCountry(countryId: string, reason?: string, scope?: WorkspaceScope): Promise<void>;
|
|
160
|
+
activateCountry(countryId: string, scope?: WorkspaceScope): Promise<void>;
|
|
161
|
+
deactivateCountry(countryId: string, scope?: WorkspaceScope): Promise<void>;
|
|
162
|
+
/** `activeOnly` is what a language picker/dropdown should pass — omit it for the Languages management page, which wants everything. */
|
|
163
|
+
listLanguages(filter?: LanguageListFilter, scope?: WorkspaceScope): Promise<LanguageListResult>;
|
|
164
|
+
createLanguage(input: CreateLanguageInput, scope?: WorkspaceScope): Promise<LanguageSummary>;
|
|
165
|
+
getLanguage(languageId: string, scope?: WorkspaceScope): Promise<LanguageSummary>;
|
|
166
|
+
updateLanguage(languageId: string, input: UpdateLanguageInput, scope?: WorkspaceScope): Promise<LanguageSummary>;
|
|
167
|
+
/** Soft-delete: the row stops appearing in listings but survives for audit purposes. */
|
|
168
|
+
deleteLanguage(languageId: string, reason?: string, scope?: WorkspaceScope): Promise<void>;
|
|
169
|
+
activateLanguage(languageId: string, scope?: WorkspaceScope): Promise<void>;
|
|
170
|
+
deactivateLanguage(languageId: string, scope?: WorkspaceScope): Promise<void>;
|
|
171
|
+
/** `activeOnly` is what a customer picker/dropdown should pass — omit it for the Customers management page, which wants everything. */
|
|
172
|
+
listCustomers(filter?: CustomerListFilter, scope?: WorkspaceScope): Promise<CustomerListResult>;
|
|
173
|
+
createCustomer(input: CreateCustomerInput, scope?: WorkspaceScope): Promise<CustomerSummary>;
|
|
174
|
+
getCustomer(customerId: string, scope?: WorkspaceScope): Promise<CustomerSummary>;
|
|
175
|
+
updateCustomer(customerId: string, input: UpdateCustomerInput, scope?: WorkspaceScope): Promise<CustomerSummary>;
|
|
176
|
+
/** Soft-delete: the row stops appearing in listings but survives for audit purposes. */
|
|
177
|
+
deleteCustomer(customerId: string, reason?: string, scope?: WorkspaceScope): Promise<void>;
|
|
178
|
+
activateCustomer(customerId: string, scope?: WorkspaceScope): Promise<void>;
|
|
179
|
+
deactivateCustomer(customerId: string, scope?: WorkspaceScope): Promise<void>;
|
|
180
|
+
/** Unauthenticated call — no Authorization header, no refresh-on-401 retry (there's no session to refresh yet). */
|
|
181
|
+
private anonymousRequest;
|
|
182
|
+
/** Authenticated and never workspace-scoped: it has no parameter with which to name a workspace. */
|
|
183
|
+
private request;
|
|
184
|
+
/** Authenticated and workspace-scoped: sends `X-Workspace-Id` when the call or the client names one. */
|
|
185
|
+
private scopedRequest;
|
|
186
|
+
/** `undefined` from here means "send no header" — the plain variant's permanent answer. */
|
|
187
|
+
private resolveWorkspaceId;
|
|
188
|
+
/**
|
|
189
|
+
* Attaches the stored access token, and on a 401 makes exactly one `refresh()` attempt before
|
|
190
|
+
* retrying once — if refresh itself fails, clears storage and surfaces the original error so
|
|
191
|
+
* the app can route to its login screen. The retry reuses the workspace already resolved for
|
|
192
|
+
* the first attempt, so a resolver function cannot answer differently mid-call.
|
|
193
|
+
*/
|
|
194
|
+
private authedRequest;
|
|
195
|
+
private rawFetch;
|
|
196
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { AuthApiError, } from "./types.js";
|
|
2
|
+
/** The header a request uses to name the workspace it acts in. Lowercased — `fetch` header names are case-insensitive. */
|
|
3
|
+
export const WORKSPACE_HEADER = "x-workspace-id";
|
|
4
|
+
function isTwoFactorChallenge(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && value.twoFactorRequired === true;
|
|
6
|
+
}
|
|
7
|
+
function queryString(filter) {
|
|
8
|
+
const params = new URLSearchParams();
|
|
9
|
+
for (const [key, value] of Object.entries(filter))
|
|
10
|
+
if (value !== undefined)
|
|
11
|
+
params.set(key, String(value));
|
|
12
|
+
const query = params.toString();
|
|
13
|
+
return query ? `?${query}` : "";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One client shared by all 4 apps (admin-nextjs, admin-react, mobile-expo, mobile-bare-rn) —
|
|
17
|
+
* the auth flow and API contract are identical, only `TokenStorage` differs per platform.
|
|
18
|
+
* Mirrors the deps-injection pattern used throughout registry/core: business logic here is
|
|
19
|
+
* storage-agnostic.
|
|
20
|
+
*
|
|
21
|
+
* It also serves both backend variants from one build. Workspaces are an install-time choice of
|
|
22
|
+
* the consuming project, not a runtime mode of this client: the workspace surface below
|
|
23
|
+
* (`createWorkspace`, `listWorkspaces`, `listWorkspaceMembers`, ...) only exists on the
|
|
24
|
+
* `--workspaces` backend, and a consumer on the plain variant simply never calls it and never
|
|
25
|
+
* configures `workspaceId` — in which case `X-Workspace-Id` is never sent on anything.
|
|
26
|
+
*
|
|
27
|
+
* Which calls carry the header is a structural property here, not a per-call decision:
|
|
28
|
+
* `request()` cannot pass a workspace at all, and only `scopedRequest()` can.
|
|
29
|
+
*/
|
|
30
|
+
export class AuthClient {
|
|
31
|
+
opts;
|
|
32
|
+
activeWorkspace;
|
|
33
|
+
constructor(opts) {
|
|
34
|
+
this.opts = opts;
|
|
35
|
+
this.activeWorkspace = opts.workspaceId ?? null;
|
|
36
|
+
}
|
|
37
|
+
// ---- active workspace (workspaces backend variant only) ----
|
|
38
|
+
/** Replaces the configured active workspace. `null` goes back to sending no workspace at all. */
|
|
39
|
+
setActiveWorkspace(workspaceId) {
|
|
40
|
+
this.activeWorkspace = workspaceId;
|
|
41
|
+
}
|
|
42
|
+
/** Resolves the active workspace — awaiting the resolver function if one was configured. */
|
|
43
|
+
async getActiveWorkspace() {
|
|
44
|
+
if (typeof this.activeWorkspace === "function")
|
|
45
|
+
return (await this.activeWorkspace()) ?? null;
|
|
46
|
+
return this.activeWorkspace;
|
|
47
|
+
}
|
|
48
|
+
// ---- identity ----
|
|
49
|
+
async signup(input) {
|
|
50
|
+
const tokens = await this.anonymousRequest("POST", "/auth/signup", input);
|
|
51
|
+
await this.opts.storage.set(tokens);
|
|
52
|
+
return tokens;
|
|
53
|
+
}
|
|
54
|
+
/** `identifier` is matched against email, username, and phone, in that order. Returns tokens directly, or a 2FA challenge to complete via `loginTwoFactor`. */
|
|
55
|
+
async login(input) {
|
|
56
|
+
const result = await this.anonymousRequest("POST", "/auth/login", input);
|
|
57
|
+
if (isTwoFactorChallenge(result))
|
|
58
|
+
return result;
|
|
59
|
+
await this.opts.storage.set(result);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
async loginTwoFactor(input) {
|
|
63
|
+
const tokens = await this.anonymousRequest("POST", "/auth/login/2fa", input);
|
|
64
|
+
await this.opts.storage.set(tokens);
|
|
65
|
+
return tokens;
|
|
66
|
+
}
|
|
67
|
+
/** Rotates the refresh token; callers rarely need this directly — `request()` calls it automatically on a 401. */
|
|
68
|
+
async refresh() {
|
|
69
|
+
const current = await this.opts.storage.get();
|
|
70
|
+
if (!current)
|
|
71
|
+
throw new AuthApiError("not logged in", 401);
|
|
72
|
+
const rotated = await this.anonymousRequest("POST", "/auth/refresh", {
|
|
73
|
+
refreshToken: current.refreshToken,
|
|
74
|
+
});
|
|
75
|
+
const merged = { ...rotated, sessionId: current.sessionId };
|
|
76
|
+
await this.opts.storage.set(merged);
|
|
77
|
+
return merged;
|
|
78
|
+
}
|
|
79
|
+
async logout() {
|
|
80
|
+
await this.request("POST", "/auth/logout");
|
|
81
|
+
await this.opts.storage.clear();
|
|
82
|
+
}
|
|
83
|
+
async logoutAll() {
|
|
84
|
+
await this.request("POST", "/auth/logout-all");
|
|
85
|
+
await this.opts.storage.clear();
|
|
86
|
+
}
|
|
87
|
+
async logoutOthers() {
|
|
88
|
+
await this.request("POST", "/auth/logout-others");
|
|
89
|
+
}
|
|
90
|
+
/** Requires the current password. Every other session is revoked; the one making this call is left alone. */
|
|
91
|
+
async changePassword(input) {
|
|
92
|
+
await this.request("POST", "/auth/password/change", input);
|
|
93
|
+
}
|
|
94
|
+
/** Self-service — no admin permission required, updates the caller's own row. Not workspace-scoped: a profile isn't a workspace concept. */
|
|
95
|
+
async updateProfile(input) {
|
|
96
|
+
return this.request("PATCH", "/auth/me", input);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The one identity endpoint that answers differently per workspace: it reports the roles and
|
|
100
|
+
* permissions that apply to *this* request, so it carries the active workspace when there is
|
|
101
|
+
* one. On the workspaces variant it is the only way to learn your permissions in a workspace,
|
|
102
|
+
* which is what the client-side UI gating reads. Pass `{ workspaceId: null }` for the
|
|
103
|
+
* workspace-free identity, which answers with empty roles and permissions.
|
|
104
|
+
*/
|
|
105
|
+
async me(scope) {
|
|
106
|
+
return this.scopedRequest("GET", "/auth/me", undefined, scope);
|
|
107
|
+
}
|
|
108
|
+
async sessions() {
|
|
109
|
+
return this.request("GET", "/auth/sessions");
|
|
110
|
+
}
|
|
111
|
+
async isAuthenticated() {
|
|
112
|
+
return (await this.opts.storage.get()) !== null;
|
|
113
|
+
}
|
|
114
|
+
async enrollTwoFactor() {
|
|
115
|
+
return this.request("POST", "/auth/2fa/enroll");
|
|
116
|
+
}
|
|
117
|
+
async confirmTwoFactor(code) {
|
|
118
|
+
return this.request("POST", "/auth/2fa/confirm", { code });
|
|
119
|
+
}
|
|
120
|
+
async disableTwoFactor(code) {
|
|
121
|
+
await this.request("POST", "/auth/2fa/disable", { code });
|
|
122
|
+
}
|
|
123
|
+
async requestPasswordReset(input) {
|
|
124
|
+
await this.anonymousRequest("POST", "/auth/password/forgot", input);
|
|
125
|
+
}
|
|
126
|
+
async resetPassword(input) {
|
|
127
|
+
await this.anonymousRequest("POST", "/auth/password/reset", input);
|
|
128
|
+
}
|
|
129
|
+
/** Returns the provider URL to send the browser/webview to; the backend handles the round-trip and callback. */
|
|
130
|
+
async oauthStart(provider) {
|
|
131
|
+
return this.anonymousRequest("GET", `/auth/oauth/${encodeURIComponent(provider)}/start`);
|
|
132
|
+
}
|
|
133
|
+
// ---- workspaces (workspaces backend variant only) ----
|
|
134
|
+
/**
|
|
135
|
+
* Not workspace-scoped — you cannot already be in the workspace you are creating, so this
|
|
136
|
+
* deliberately sends no `X-Workspace-Id`. The creator becomes an `["admin","member"]` member.
|
|
137
|
+
*/
|
|
138
|
+
async createWorkspace(name) {
|
|
139
|
+
return this.request("POST", "/workspaces", { name });
|
|
140
|
+
}
|
|
141
|
+
/** Not workspace-scoped: any authenticated user, answering with the workspaces they belong to and their roles in each. */
|
|
142
|
+
async listWorkspaces() {
|
|
143
|
+
return this.request("GET", "/workspaces");
|
|
144
|
+
}
|
|
145
|
+
/** Any member of the workspace may list it. */
|
|
146
|
+
async listWorkspaceMembers(scope) {
|
|
147
|
+
return this.scopedRequest("GET", "/workspaces/members", undefined, scope);
|
|
148
|
+
}
|
|
149
|
+
/** [admin] Adds an existing user by email. `roles` defaults to `["member"]` on the backend. */
|
|
150
|
+
async addWorkspaceMember(input, scope) {
|
|
151
|
+
return this.scopedRequest("POST", "/workspaces/members", input, scope);
|
|
152
|
+
}
|
|
153
|
+
/** [admin] Replaces a member's whole role set. The backend refuses (403) when the member is you. */
|
|
154
|
+
async setWorkspaceMemberRoles(memberId, roles, scope) {
|
|
155
|
+
return this.scopedRequest("PUT", `/workspaces/members/${encodeURIComponent(memberId)}/roles`, { roles }, scope);
|
|
156
|
+
}
|
|
157
|
+
/** [admin] Removes a member, and their direct permission grants with them. The backend refuses (403) when the member is you. */
|
|
158
|
+
async removeWorkspaceMember(memberId, scope) {
|
|
159
|
+
await this.scopedRequest("DELETE", `/workspaces/members/${encodeURIComponent(memberId)}`, undefined, scope);
|
|
160
|
+
}
|
|
161
|
+
// ---- administration ----
|
|
162
|
+
//
|
|
163
|
+
// Every call below is workspace-scoped: on the workspaces variant these routes administer one
|
|
164
|
+
// workspace and require the header; on the plain variant they administer the deployment and
|
|
165
|
+
// the header is never configured, so never sent.
|
|
166
|
+
async blockUser(userId, scope) {
|
|
167
|
+
await this.scopedRequest("POST", `/auth/admin/users/${encodeURIComponent(userId)}/block`, undefined, scope);
|
|
168
|
+
}
|
|
169
|
+
async unblockUser(userId, scope) {
|
|
170
|
+
await this.scopedRequest("POST", `/auth/admin/users/${encodeURIComponent(userId)}/unblock`, undefined, scope);
|
|
171
|
+
}
|
|
172
|
+
/** A routine administrative on/off toggle — distinct from block/unblock, a security/moderation action. Both independently deny login. */
|
|
173
|
+
async deactivateUser(userId, scope) {
|
|
174
|
+
await this.scopedRequest("POST", `/auth/admin/users/${encodeURIComponent(userId)}/deactivate`, undefined, scope);
|
|
175
|
+
}
|
|
176
|
+
async activateUser(userId, scope) {
|
|
177
|
+
await this.scopedRequest("POST", `/auth/admin/users/${encodeURIComponent(userId)}/activate`, undefined, scope);
|
|
178
|
+
}
|
|
179
|
+
async listUsers(filter = {}, scope) {
|
|
180
|
+
return this.scopedRequest("GET", `/auth/admin/users${queryString(filter)}`, undefined, scope);
|
|
181
|
+
}
|
|
182
|
+
/** No invitation email — the account is usable immediately with the password given here. */
|
|
183
|
+
async createUser(input, scope) {
|
|
184
|
+
return this.scopedRequest("POST", "/auth/admin/users", input, scope);
|
|
185
|
+
}
|
|
186
|
+
async getUser(userId, scope) {
|
|
187
|
+
return this.scopedRequest("GET", `/auth/admin/users/${encodeURIComponent(userId)}`, undefined, scope);
|
|
188
|
+
}
|
|
189
|
+
/** Profile fields only — email is the login identifier and is not editable here. */
|
|
190
|
+
async updateUser(userId, input, scope) {
|
|
191
|
+
return this.scopedRequest("PATCH", `/auth/admin/users/${encodeURIComponent(userId)}`, input, scope);
|
|
192
|
+
}
|
|
193
|
+
/** Soft-delete: the account stops appearing in listings and can no longer authenticate. The backend refuses (403) when the target is you. */
|
|
194
|
+
async deleteUser(userId, reason, scope) {
|
|
195
|
+
await this.scopedRequest("DELETE", `/auth/admin/users/${encodeURIComponent(userId)}`, reason ? { reason } : undefined, scope);
|
|
196
|
+
}
|
|
197
|
+
/** `activeOnly` is what a role picker (Add user, Assign role) should pass — omit it for the Roles management page, which wants everything. */
|
|
198
|
+
async listRoles(filter = {}, scope) {
|
|
199
|
+
const result = await this.scopedRequest("GET", `/auth/admin/roles${queryString(filter)}`, undefined, scope);
|
|
200
|
+
return result.roles;
|
|
201
|
+
}
|
|
202
|
+
async createRole(input, scope) {
|
|
203
|
+
return this.scopedRequest("POST", "/auth/admin/roles", input, scope);
|
|
204
|
+
}
|
|
205
|
+
async updateRole(roleId, input, scope) {
|
|
206
|
+
return this.scopedRequest("PATCH", `/auth/admin/roles/${encodeURIComponent(roleId)}`, input, scope);
|
|
207
|
+
}
|
|
208
|
+
/** Soft-delete: existing assignments are left in place, and the role simply stops being resolved. */
|
|
209
|
+
async deleteRole(roleId, reason, scope) {
|
|
210
|
+
await this.scopedRequest("DELETE", `/auth/admin/roles/${encodeURIComponent(roleId)}`, reason ? { reason } : undefined, scope);
|
|
211
|
+
}
|
|
212
|
+
async attachPermissionToRole(roleId, permission, scope) {
|
|
213
|
+
await this.scopedRequest("POST", `/auth/admin/roles/${encodeURIComponent(roleId)}/permissions`, { permission }, scope);
|
|
214
|
+
}
|
|
215
|
+
async detachPermissionFromRole(roleId, permissionSlug, scope) {
|
|
216
|
+
await this.scopedRequest("POST", `/auth/admin/roles/${encodeURIComponent(roleId)}/permissions/${encodeURIComponent(permissionSlug)}/revoke`, undefined, scope);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The catalog itself is global on both variants; what is workspace-scoped is which roles/
|
|
220
|
+
* memberships point at each row. Still sent through `scopedRequest`: on the workspaces variant
|
|
221
|
+
* every `/auth/admin/*` route sits behind the same guard that requires `X-Workspace-Id`, even
|
|
222
|
+
* this one. `activeOnly` is what a permission picker (Create/Edit Role) should pass — omit it
|
|
223
|
+
* for the Permissions management page, which wants everything.
|
|
224
|
+
*/
|
|
225
|
+
async listPermissions(filter = {}, scope) {
|
|
226
|
+
const result = await this.scopedRequest("GET", `/auth/admin/permissions${queryString(filter)}`, undefined, scope);
|
|
227
|
+
return result.permissions;
|
|
228
|
+
}
|
|
229
|
+
/** Upserted on `slug` — creates a new capability, or edits/deactivates an existing one. */
|
|
230
|
+
async definePermission(input, scope) {
|
|
231
|
+
return this.scopedRequest("POST", "/auth/admin/permissions", input, scope);
|
|
232
|
+
}
|
|
233
|
+
async assignRole(userId, role, scope) {
|
|
234
|
+
await this.scopedRequest("POST", `/auth/admin/users/${encodeURIComponent(userId)}/roles`, { role }, scope);
|
|
235
|
+
}
|
|
236
|
+
async revokeRole(userId, roleName, scope) {
|
|
237
|
+
await this.scopedRequest("POST", `/auth/admin/users/${encodeURIComponent(userId)}/roles/${encodeURIComponent(roleName)}/revoke`, undefined, scope);
|
|
238
|
+
}
|
|
239
|
+
async grantPermission(userId, permission, scope) {
|
|
240
|
+
await this.scopedRequest("POST", `/auth/admin/users/${encodeURIComponent(userId)}/permissions`, { permission }, scope);
|
|
241
|
+
}
|
|
242
|
+
async revokePermission(userId, permissionKey, scope) {
|
|
243
|
+
await this.scopedRequest("POST", `/auth/admin/users/${encodeURIComponent(userId)}/permissions/${encodeURIComponent(permissionKey)}/revoke`, undefined, scope);
|
|
244
|
+
}
|
|
245
|
+
async listAuditLog(filter = {}, scope) {
|
|
246
|
+
return this.scopedRequest("GET", `/auth/admin/audit-log${queryString(filter)}`, undefined, scope);
|
|
247
|
+
}
|
|
248
|
+
// ---- countries (base backend variant only) ----
|
|
249
|
+
/** `activeOnly` is what a country picker/dropdown should pass — omit it for the Countries management page, which wants everything. */
|
|
250
|
+
async listCountries(filter = {}, scope) {
|
|
251
|
+
return this.scopedRequest("GET", `/auth/admin/countries${queryString(filter)}`, undefined, scope);
|
|
252
|
+
}
|
|
253
|
+
async createCountry(input, scope) {
|
|
254
|
+
return this.scopedRequest("POST", "/auth/admin/countries", input, scope);
|
|
255
|
+
}
|
|
256
|
+
async getCountry(countryId, scope) {
|
|
257
|
+
return this.scopedRequest("GET", `/auth/admin/countries/${encodeURIComponent(countryId)}`, undefined, scope);
|
|
258
|
+
}
|
|
259
|
+
async updateCountry(countryId, input, scope) {
|
|
260
|
+
return this.scopedRequest("PATCH", `/auth/admin/countries/${encodeURIComponent(countryId)}`, input, scope);
|
|
261
|
+
}
|
|
262
|
+
/** Soft-delete: the row stops appearing in listings but survives for audit purposes. */
|
|
263
|
+
async deleteCountry(countryId, reason, scope) {
|
|
264
|
+
await this.scopedRequest("DELETE", `/auth/admin/countries/${encodeURIComponent(countryId)}`, reason ? { reason } : undefined, scope);
|
|
265
|
+
}
|
|
266
|
+
async activateCountry(countryId, scope) {
|
|
267
|
+
await this.scopedRequest("POST", `/auth/admin/countries/${encodeURIComponent(countryId)}/activate`, undefined, scope);
|
|
268
|
+
}
|
|
269
|
+
async deactivateCountry(countryId, scope) {
|
|
270
|
+
await this.scopedRequest("POST", `/auth/admin/countries/${encodeURIComponent(countryId)}/deactivate`, undefined, scope);
|
|
271
|
+
}
|
|
272
|
+
// ---- languages (base backend variant only) ----
|
|
273
|
+
/** `activeOnly` is what a language picker/dropdown should pass — omit it for the Languages management page, which wants everything. */
|
|
274
|
+
async listLanguages(filter = {}, scope) {
|
|
275
|
+
return this.scopedRequest("GET", `/auth/admin/languages${queryString(filter)}`, undefined, scope);
|
|
276
|
+
}
|
|
277
|
+
async createLanguage(input, scope) {
|
|
278
|
+
return this.scopedRequest("POST", "/auth/admin/languages", input, scope);
|
|
279
|
+
}
|
|
280
|
+
async getLanguage(languageId, scope) {
|
|
281
|
+
return this.scopedRequest("GET", `/auth/admin/languages/${encodeURIComponent(languageId)}`, undefined, scope);
|
|
282
|
+
}
|
|
283
|
+
async updateLanguage(languageId, input, scope) {
|
|
284
|
+
return this.scopedRequest("PATCH", `/auth/admin/languages/${encodeURIComponent(languageId)}`, input, scope);
|
|
285
|
+
}
|
|
286
|
+
/** Soft-delete: the row stops appearing in listings but survives for audit purposes. */
|
|
287
|
+
async deleteLanguage(languageId, reason, scope) {
|
|
288
|
+
await this.scopedRequest("DELETE", `/auth/admin/languages/${encodeURIComponent(languageId)}`, reason ? { reason } : undefined, scope);
|
|
289
|
+
}
|
|
290
|
+
async activateLanguage(languageId, scope) {
|
|
291
|
+
await this.scopedRequest("POST", `/auth/admin/languages/${encodeURIComponent(languageId)}/activate`, undefined, scope);
|
|
292
|
+
}
|
|
293
|
+
async deactivateLanguage(languageId, scope) {
|
|
294
|
+
await this.scopedRequest("POST", `/auth/admin/languages/${encodeURIComponent(languageId)}/deactivate`, undefined, scope);
|
|
295
|
+
}
|
|
296
|
+
// ---- customers (base backend variant only) ----
|
|
297
|
+
// End-users managed by admins — no login capability, and not related to the admin UserSummary above.
|
|
298
|
+
/** `activeOnly` is what a customer picker/dropdown should pass — omit it for the Customers management page, which wants everything. */
|
|
299
|
+
async listCustomers(filter = {}, scope) {
|
|
300
|
+
return this.scopedRequest("GET", `/auth/admin/customers${queryString(filter)}`, undefined, scope);
|
|
301
|
+
}
|
|
302
|
+
async createCustomer(input, scope) {
|
|
303
|
+
return this.scopedRequest("POST", "/auth/admin/customers", input, scope);
|
|
304
|
+
}
|
|
305
|
+
async getCustomer(customerId, scope) {
|
|
306
|
+
return this.scopedRequest("GET", `/auth/admin/customers/${encodeURIComponent(customerId)}`, undefined, scope);
|
|
307
|
+
}
|
|
308
|
+
async updateCustomer(customerId, input, scope) {
|
|
309
|
+
return this.scopedRequest("PATCH", `/auth/admin/customers/${encodeURIComponent(customerId)}`, input, scope);
|
|
310
|
+
}
|
|
311
|
+
/** Soft-delete: the row stops appearing in listings but survives for audit purposes. */
|
|
312
|
+
async deleteCustomer(customerId, reason, scope) {
|
|
313
|
+
await this.scopedRequest("DELETE", `/auth/admin/customers/${encodeURIComponent(customerId)}`, reason ? { reason } : undefined, scope);
|
|
314
|
+
}
|
|
315
|
+
async activateCustomer(customerId, scope) {
|
|
316
|
+
await this.scopedRequest("POST", `/auth/admin/customers/${encodeURIComponent(customerId)}/activate`, undefined, scope);
|
|
317
|
+
}
|
|
318
|
+
async deactivateCustomer(customerId, scope) {
|
|
319
|
+
await this.scopedRequest("POST", `/auth/admin/customers/${encodeURIComponent(customerId)}/deactivate`, undefined, scope);
|
|
320
|
+
}
|
|
321
|
+
// ---- transport ----
|
|
322
|
+
/** Unauthenticated call — no Authorization header, no refresh-on-401 retry (there's no session to refresh yet). */
|
|
323
|
+
async anonymousRequest(method, path, body) {
|
|
324
|
+
return this.rawFetch(method, path, body);
|
|
325
|
+
}
|
|
326
|
+
/** Authenticated and never workspace-scoped: it has no parameter with which to name a workspace. */
|
|
327
|
+
async request(method, path, body) {
|
|
328
|
+
return this.authedRequest(method, path, body, undefined);
|
|
329
|
+
}
|
|
330
|
+
/** Authenticated and workspace-scoped: sends `X-Workspace-Id` when the call or the client names one. */
|
|
331
|
+
async scopedRequest(method, path, body, scope) {
|
|
332
|
+
return this.authedRequest(method, path, body, await this.resolveWorkspaceId(scope));
|
|
333
|
+
}
|
|
334
|
+
/** `undefined` from here means "send no header" — the plain variant's permanent answer. */
|
|
335
|
+
async resolveWorkspaceId(scope) {
|
|
336
|
+
if (scope && scope.workspaceId !== undefined)
|
|
337
|
+
return scope.workspaceId ?? undefined;
|
|
338
|
+
return (await this.getActiveWorkspace()) ?? undefined;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Attaches the stored access token, and on a 401 makes exactly one `refresh()` attempt before
|
|
342
|
+
* retrying once — if refresh itself fails, clears storage and surfaces the original error so
|
|
343
|
+
* the app can route to its login screen. The retry reuses the workspace already resolved for
|
|
344
|
+
* the first attempt, so a resolver function cannot answer differently mid-call.
|
|
345
|
+
*/
|
|
346
|
+
async authedRequest(method, path, body, workspaceId, isRetry = false) {
|
|
347
|
+
const tokens = await this.opts.storage.get();
|
|
348
|
+
if (!tokens)
|
|
349
|
+
throw new AuthApiError("not logged in", 401);
|
|
350
|
+
try {
|
|
351
|
+
return await this.rawFetch(method, path, body, tokens.accessToken, workspaceId);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
if (err instanceof AuthApiError && err.status === 401 && !isRetry) {
|
|
355
|
+
try {
|
|
356
|
+
await this.refresh();
|
|
357
|
+
}
|
|
358
|
+
catch (refreshErr) {
|
|
359
|
+
await this.opts.storage.clear();
|
|
360
|
+
throw refreshErr;
|
|
361
|
+
}
|
|
362
|
+
return this.authedRequest(method, path, body, workspaceId, true);
|
|
363
|
+
}
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async rawFetch(method, path, body, bearerToken, workspaceId) {
|
|
368
|
+
const response = await fetch(`${this.opts.baseUrl}${path}`, {
|
|
369
|
+
method,
|
|
370
|
+
headers: {
|
|
371
|
+
"content-type": "application/json",
|
|
372
|
+
...(bearerToken ? { authorization: `Bearer ${bearerToken}` } : {}),
|
|
373
|
+
...(workspaceId ? { [WORKSPACE_HEADER]: workspaceId } : {}),
|
|
374
|
+
},
|
|
375
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
376
|
+
});
|
|
377
|
+
const envelope = await response.json().catch(() => undefined);
|
|
378
|
+
if (!response.ok) {
|
|
379
|
+
const errorBody = envelope && typeof envelope === "object" ? envelope : {};
|
|
380
|
+
const message = typeof errorBody.message === "string" ? errorBody.message : response.statusText;
|
|
381
|
+
const code = typeof errorBody.code === "string" ? errorBody.code : undefined;
|
|
382
|
+
throw new AuthApiError(message, response.status, code);
|
|
383
|
+
}
|
|
384
|
+
// Every successful response is `{success, statusCode, message, data}` — unwrapped here, once,
|
|
385
|
+
// so every method above returns the payload directly and never has to know the envelope exists.
|
|
386
|
+
const payload = envelope && typeof envelope === "object" && "data" in envelope ? envelope.data : envelope;
|
|
387
|
+
return payload;
|
|
388
|
+
}
|
|
389
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
export interface AuthTokens {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
refreshToken: string;
|
|
4
|
+
/** Only present right after signup/login/2fa-complete — refresh() doesn't get a new one, so it's carried forward from storage. */
|
|
5
|
+
sessionId?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TwoFactorChallenge {
|
|
8
|
+
twoFactorRequired: true;
|
|
9
|
+
challengeToken: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `GET /auth/me`. Verified against a running backend: the server answers exactly these five
|
|
13
|
+
* fields, on both variants.
|
|
14
|
+
*
|
|
15
|
+
* The access token is workspace-agnostic — it identifies only the user and the session — so
|
|
16
|
+
* `roles`/`permissions` are whatever applies to *this request*: on the plain backend variant
|
|
17
|
+
* they are global to the deployment, and on the workspaces variant they are the caller's roles
|
|
18
|
+
* and permissions inside the workspace the request named, or empty when it named none.
|
|
19
|
+
*
|
|
20
|
+
* A `rules` field is coming: the caller's CASL abilities, serialized with `packRules` from
|
|
21
|
+
* `@casl/ability/extra`, for the workspace this request names. It belongs *here*, as one more
|
|
22
|
+
* optional property of this interface, and nowhere else — the client neither unpacks it nor
|
|
23
|
+
* depends on `@casl/ability`; each consuming app builds its own `Ability` from it, so server
|
|
24
|
+
* enforcement and UI hiding cannot drift. Nothing in this package has to change for the field to
|
|
25
|
+
* arrive safely: responses are handed back whole, never rebuilt from a known-field list, so an
|
|
26
|
+
* unrecognised field already reaches the caller untouched (see the `/auth/me` passthrough test).
|
|
27
|
+
*/
|
|
28
|
+
export interface CurrentUser {
|
|
29
|
+
sub: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
roles: string[];
|
|
32
|
+
permissions: string[];
|
|
33
|
+
twoFactorEnabled: boolean;
|
|
34
|
+
email: string;
|
|
35
|
+
firstName: string | null;
|
|
36
|
+
lastName: string | null;
|
|
37
|
+
displayName: string | null;
|
|
38
|
+
phone: string | null;
|
|
39
|
+
username: string | null;
|
|
40
|
+
photo: string | null;
|
|
41
|
+
}
|
|
42
|
+
export interface SessionSummary {
|
|
43
|
+
id: string;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
userAgent?: string;
|
|
46
|
+
ip?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface AuditLogEntry {
|
|
49
|
+
id: string;
|
|
50
|
+
/** Workspaces variant only; null for events that happened outside any workspace. Absent on the plain variant. */
|
|
51
|
+
workspaceId?: string | null;
|
|
52
|
+
userId: string | null;
|
|
53
|
+
/** Human-readable label for `action`, e.g. "Role assigned". */
|
|
54
|
+
name: string;
|
|
55
|
+
/** The AuditEvent discriminant, e.g. "role_assigned". */
|
|
56
|
+
action: string;
|
|
57
|
+
/** The rest of the AuditEvent's fields. */
|
|
58
|
+
info: unknown;
|
|
59
|
+
/** Free-text note; null unless the backend set one. */
|
|
60
|
+
remarks: string | null;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
updatedAt: string;
|
|
63
|
+
}
|
|
64
|
+
export interface PageMeta {
|
|
65
|
+
/** 1-indexed. */
|
|
66
|
+
page: number;
|
|
67
|
+
limit: number;
|
|
68
|
+
total: number;
|
|
69
|
+
pageCount: number;
|
|
70
|
+
hasPreviousPage: boolean;
|
|
71
|
+
hasNextPage: boolean;
|
|
72
|
+
}
|
|
73
|
+
export interface AuditLogListFilter {
|
|
74
|
+
userId?: string;
|
|
75
|
+
action?: string;
|
|
76
|
+
since?: string;
|
|
77
|
+
until?: string;
|
|
78
|
+
/** 1-indexed. Defaults to 1. */
|
|
79
|
+
page?: number;
|
|
80
|
+
/** Defaults to 25, capped at 100. */
|
|
81
|
+
limit?: number;
|
|
82
|
+
}
|
|
83
|
+
export interface AuditLogListResult {
|
|
84
|
+
items: AuditLogEntry[];
|
|
85
|
+
meta: PageMeta;
|
|
86
|
+
}
|
|
87
|
+
export interface RoleSummary {
|
|
88
|
+
id: string;
|
|
89
|
+
slug: string;
|
|
90
|
+
/** Unique across the deployment, alongside slug/displayName. */
|
|
91
|
+
name: string;
|
|
92
|
+
displayName: string;
|
|
93
|
+
/** Given to every newly signed-up user (or, on the workspaces variant, every new membership). */
|
|
94
|
+
isDefault: boolean;
|
|
95
|
+
isActive: boolean;
|
|
96
|
+
/** Permission slugs currently attached to this role — see attachPermissionToRole/detachPermissionFromRole. */
|
|
97
|
+
permissions: string[];
|
|
98
|
+
}
|
|
99
|
+
export interface CreateRoleInput {
|
|
100
|
+
/** Stable identifier. Grants and assignments are keyed on it. */
|
|
101
|
+
slug: string;
|
|
102
|
+
name?: string;
|
|
103
|
+
/** Human label for the console. Defaults to the slug. */
|
|
104
|
+
displayName?: string;
|
|
105
|
+
description?: string | null;
|
|
106
|
+
/** Given to every newly signed-up user. Defaults to false. */
|
|
107
|
+
isDefault?: boolean;
|
|
108
|
+
/** Defaults to true. */
|
|
109
|
+
isActive?: boolean;
|
|
110
|
+
}
|
|
111
|
+
export interface UpdateRoleInput {
|
|
112
|
+
name?: string;
|
|
113
|
+
displayName?: string;
|
|
114
|
+
description?: string | null;
|
|
115
|
+
/** Given to every newly signed-up user. */
|
|
116
|
+
isDefault?: boolean;
|
|
117
|
+
/** `false` suspends the role without deleting it — it stops granting immediately. */
|
|
118
|
+
isActive?: boolean;
|
|
119
|
+
}
|
|
120
|
+
export interface PermissionSummary {
|
|
121
|
+
id: string;
|
|
122
|
+
/** The ability itself: `@CheckAbility(slug)` on the server, `ability.can(slug, ABILITY_SUBJECT)` in a client. */
|
|
123
|
+
slug: string;
|
|
124
|
+
/** Unique across the deployment, alongside slug/displayName. */
|
|
125
|
+
name: string;
|
|
126
|
+
displayName: string;
|
|
127
|
+
description: string | null;
|
|
128
|
+
/** Console grouping — a permission matrix renders one section per group. */
|
|
129
|
+
group: string;
|
|
130
|
+
groupOrder: number;
|
|
131
|
+
order: number;
|
|
132
|
+
/** `false` takes the permission out of every ability that would otherwise carry it, without unpicking a single grant. */
|
|
133
|
+
isActive: boolean;
|
|
134
|
+
}
|
|
135
|
+
export interface DefinePermissionInput {
|
|
136
|
+
slug: string;
|
|
137
|
+
name?: string;
|
|
138
|
+
/** Defaults to the slug when creating. */
|
|
139
|
+
displayName?: string;
|
|
140
|
+
description?: string | null;
|
|
141
|
+
/** Defaults to `"Custom"` when creating. */
|
|
142
|
+
group?: string;
|
|
143
|
+
groupOrder?: number;
|
|
144
|
+
order?: number;
|
|
145
|
+
isActive?: boolean;
|
|
146
|
+
}
|
|
147
|
+
export interface PermissionListResult {
|
|
148
|
+
permissions: PermissionSummary[];
|
|
149
|
+
}
|
|
150
|
+
/** Profile fields a `PATCH /auth/admin/users/:userId` accepts. Email is the login identifier and is not editable here. */
|
|
151
|
+
export interface UpdateUserInput {
|
|
152
|
+
firstName?: string | null;
|
|
153
|
+
lastName?: string | null;
|
|
154
|
+
displayName?: string | null;
|
|
155
|
+
phone?: string | null;
|
|
156
|
+
username?: string | null;
|
|
157
|
+
photo?: string | null;
|
|
158
|
+
/** ISO date (yyyy-mm-dd). */
|
|
159
|
+
dob?: string | null;
|
|
160
|
+
gender?: string | null;
|
|
161
|
+
/** ISO date (yyyy-mm-dd). Not nullable — omit to leave unchanged. */
|
|
162
|
+
joinedDate?: string;
|
|
163
|
+
}
|
|
164
|
+
/** `PATCH /auth/me`'s response — the same shape whether or not the combo has workspaces, since a profile isn't a workspace concept. */
|
|
165
|
+
export interface SelfProfile {
|
|
166
|
+
id: string;
|
|
167
|
+
email: string;
|
|
168
|
+
firstName: string | null;
|
|
169
|
+
lastName: string | null;
|
|
170
|
+
displayName: string | null;
|
|
171
|
+
phone: string | null;
|
|
172
|
+
username: string | null;
|
|
173
|
+
photo: string | null;
|
|
174
|
+
createdAt: string;
|
|
175
|
+
}
|
|
176
|
+
/** `POST /auth/admin/users` — no invitation email, the account is usable immediately. */
|
|
177
|
+
export interface CreateUserInput {
|
|
178
|
+
email: string;
|
|
179
|
+
password: string;
|
|
180
|
+
firstName?: string;
|
|
181
|
+
lastName?: string;
|
|
182
|
+
displayName?: string;
|
|
183
|
+
phone?: string;
|
|
184
|
+
username?: string;
|
|
185
|
+
photo?: string;
|
|
186
|
+
/** ISO date (yyyy-mm-dd). */
|
|
187
|
+
dob?: string;
|
|
188
|
+
gender?: string;
|
|
189
|
+
/** ISO date (yyyy-mm-dd). Defaults to today. */
|
|
190
|
+
joinedDate?: string;
|
|
191
|
+
/** Defaults to true — false creates the account already deactivated. */
|
|
192
|
+
isActive?: boolean;
|
|
193
|
+
/** Defaults to whichever roles are flagged `isDefault`, same as a self-signup. */
|
|
194
|
+
roles?: string[];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Every column the `User` table has, except the two secrets (`passwordHash`, `twoFactorSecret` —
|
|
198
|
+
* never leave the server). Deliberately not a hand-picked subset — a curated field list means
|
|
199
|
+
* every field a future screen wants is another round-trip through the backend, the client, and
|
|
200
|
+
* the UI; forwarding the whole safe row once means that never has to happen again.
|
|
201
|
+
*/
|
|
202
|
+
export interface DeploymentUserSummary {
|
|
203
|
+
id: string;
|
|
204
|
+
uuid: string;
|
|
205
|
+
email: string;
|
|
206
|
+
firstName: string | null;
|
|
207
|
+
lastName: string | null;
|
|
208
|
+
displayName: string | null;
|
|
209
|
+
phone: string | null;
|
|
210
|
+
username: string | null;
|
|
211
|
+
photo: string | null;
|
|
212
|
+
/** Date of birth, ISO 8601. */
|
|
213
|
+
dob: string | null;
|
|
214
|
+
gender: string | null;
|
|
215
|
+
/** ISO 8601. Defaults to the account's creation day. */
|
|
216
|
+
joinedDate: string;
|
|
217
|
+
/** ISO 8601. Set on every successful signup/login/OAuth callback, never on a token refresh. */
|
|
218
|
+
lastLogin: string | null;
|
|
219
|
+
/** Security/moderation block (see `blockUser`/`unblockUser`) — distinct from `isActive`. Both independently deny login. */
|
|
220
|
+
blocked: boolean;
|
|
221
|
+
/** Routine administrative on/off toggle (see `activateUser`/`deactivateUser`) — distinct from `blocked`. Both independently deny login. */
|
|
222
|
+
isActive: boolean;
|
|
223
|
+
twoFactorEnabled: boolean;
|
|
224
|
+
roles: string[];
|
|
225
|
+
createdBy: string | null;
|
|
226
|
+
updatedBy: string | null;
|
|
227
|
+
createdAt: string;
|
|
228
|
+
updatedAt: string;
|
|
229
|
+
}
|
|
230
|
+
/** `GET /auth/admin/users` on the workspaces variant: the principal is a membership of one workspace. Same "every safe column" policy as `DeploymentUserSummary`. */
|
|
231
|
+
export interface WorkspaceUserSummary {
|
|
232
|
+
/** WorkspaceMember id — the handle for member-scoped operations on /workspaces/members/*. */
|
|
233
|
+
memberId: string;
|
|
234
|
+
userId: string;
|
|
235
|
+
uuid: string;
|
|
236
|
+
email: string;
|
|
237
|
+
firstName: string | null;
|
|
238
|
+
lastName: string | null;
|
|
239
|
+
displayName: string | null;
|
|
240
|
+
phone: string | null;
|
|
241
|
+
username: string | null;
|
|
242
|
+
photo: string | null;
|
|
243
|
+
/** Date of birth, ISO 8601. */
|
|
244
|
+
dob: string | null;
|
|
245
|
+
gender: string | null;
|
|
246
|
+
/** ISO 8601. Defaults to the account's creation day. */
|
|
247
|
+
joinedDate: string;
|
|
248
|
+
lastLogin: string | null;
|
|
249
|
+
blocked: boolean;
|
|
250
|
+
isActive: boolean;
|
|
251
|
+
twoFactorEnabled: boolean;
|
|
252
|
+
roles: string[];
|
|
253
|
+
createdBy: string | null;
|
|
254
|
+
updatedBy: string | null;
|
|
255
|
+
createdAt: string;
|
|
256
|
+
updatedAt: string;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The two backend variants name the listed principal differently, and a consumer compiled
|
|
260
|
+
* against one of them narrows to that arm. `userIdOf()` covers the common case — the id the
|
|
261
|
+
* `/auth/admin/users/:userId/*` routes take, which is the User id on both variants.
|
|
262
|
+
*/
|
|
263
|
+
export type UserSummary = DeploymentUserSummary | WorkspaceUserSummary;
|
|
264
|
+
export declare function userIdOf(user: UserSummary): string;
|
|
265
|
+
export interface UserListFilter {
|
|
266
|
+
search?: string;
|
|
267
|
+
/** 1-indexed. Defaults to 1. */
|
|
268
|
+
page?: number;
|
|
269
|
+
/** Defaults to 25, capped at 100. */
|
|
270
|
+
limit?: number;
|
|
271
|
+
}
|
|
272
|
+
export interface UserListResult {
|
|
273
|
+
items: UserSummary[];
|
|
274
|
+
meta: PageMeta;
|
|
275
|
+
}
|
|
276
|
+
export interface WorkspaceSummary {
|
|
277
|
+
id: string;
|
|
278
|
+
name: string;
|
|
279
|
+
createdAt: string;
|
|
280
|
+
/** The calling user's roles in this workspace. */
|
|
281
|
+
roles: string[];
|
|
282
|
+
}
|
|
283
|
+
export interface WorkspaceMember {
|
|
284
|
+
memberId: string;
|
|
285
|
+
userId: string;
|
|
286
|
+
email: string;
|
|
287
|
+
roles: string[];
|
|
288
|
+
createdAt: string;
|
|
289
|
+
}
|
|
290
|
+
export interface MemberRoles {
|
|
291
|
+
memberId: string;
|
|
292
|
+
roles: string[];
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Per-call workspace override for the calls that are workspace-scoped.
|
|
296
|
+
*
|
|
297
|
+
* Omitted (or `workspaceId: undefined`) uses the client's active workspace, which is the normal
|
|
298
|
+
* case — a consumer sets it once. A string acts in that workspace for this one call. `null`
|
|
299
|
+
* suppresses the header entirely, which is how you ask `/auth/me` for the workspace-free
|
|
300
|
+
* identity while a workspace is active.
|
|
301
|
+
*/
|
|
302
|
+
export interface WorkspaceScope {
|
|
303
|
+
workspaceId?: string | null;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The active workspace, resolved per request. A function keeps ownership of *where the active
|
|
307
|
+
* workspace lives* with the app — a Zustand store, a route param, AsyncStorage — the same way
|
|
308
|
+
* `TokenStorage` keeps token persistence out of this package.
|
|
309
|
+
*/
|
|
310
|
+
export type WorkspaceIdResolver = () => string | null | undefined | Promise<string | null | undefined>;
|
|
311
|
+
/** Injected per platform — the only thing that differs between the 4 apps. */
|
|
312
|
+
export interface TokenStorage {
|
|
313
|
+
get(): Promise<AuthTokens | null>;
|
|
314
|
+
set(tokens: AuthTokens): Promise<void>;
|
|
315
|
+
clear(): Promise<void>;
|
|
316
|
+
}
|
|
317
|
+
/** Every column the `Country` table has — there is no secret to withhold, unlike `UserSummary`. */
|
|
318
|
+
export interface CountrySummary {
|
|
319
|
+
id: string;
|
|
320
|
+
uuid: string;
|
|
321
|
+
code: string;
|
|
322
|
+
name: string;
|
|
323
|
+
emoji: string;
|
|
324
|
+
phoneCode: string;
|
|
325
|
+
currency: string;
|
|
326
|
+
currencyName: string;
|
|
327
|
+
isoCode: string;
|
|
328
|
+
/** A data URI or an externally-hosted URL — stored as-is, never processed server-side. */
|
|
329
|
+
flag: string | null;
|
|
330
|
+
isActive: boolean;
|
|
331
|
+
createdBy: string | null;
|
|
332
|
+
updatedBy: string | null;
|
|
333
|
+
createdAt: string;
|
|
334
|
+
updatedAt: string;
|
|
335
|
+
}
|
|
336
|
+
export interface CreateCountryInput {
|
|
337
|
+
code: string;
|
|
338
|
+
name: string;
|
|
339
|
+
emoji: string;
|
|
340
|
+
phoneCode: string;
|
|
341
|
+
currency: string;
|
|
342
|
+
currencyName: string;
|
|
343
|
+
isoCode: string;
|
|
344
|
+
flag?: string | null;
|
|
345
|
+
isActive?: boolean;
|
|
346
|
+
}
|
|
347
|
+
export interface UpdateCountryInput {
|
|
348
|
+
code?: string;
|
|
349
|
+
name?: string;
|
|
350
|
+
emoji?: string;
|
|
351
|
+
phoneCode?: string;
|
|
352
|
+
currency?: string;
|
|
353
|
+
currencyName?: string;
|
|
354
|
+
isoCode?: string;
|
|
355
|
+
flag?: string | null;
|
|
356
|
+
isActive?: boolean;
|
|
357
|
+
}
|
|
358
|
+
export interface CountryListFilter {
|
|
359
|
+
search?: string;
|
|
360
|
+
/** 1-indexed. Defaults to 1. */
|
|
361
|
+
page?: number;
|
|
362
|
+
/** Defaults to 25, capped at 100. */
|
|
363
|
+
limit?: number;
|
|
364
|
+
/** Pass true for a picker/dropdown — omitted returns everything, active or not. */
|
|
365
|
+
activeOnly?: boolean;
|
|
366
|
+
}
|
|
367
|
+
export interface CountryListResult {
|
|
368
|
+
items: CountrySummary[];
|
|
369
|
+
meta: PageMeta;
|
|
370
|
+
}
|
|
371
|
+
/** Every column the `Language` table has — there is no secret to withhold, unlike `UserSummary`. */
|
|
372
|
+
export interface LanguageSummary {
|
|
373
|
+
id: string;
|
|
374
|
+
uuid: string;
|
|
375
|
+
code: string;
|
|
376
|
+
name: string;
|
|
377
|
+
nativeName: string;
|
|
378
|
+
direction: "ltr" | "rtl" | (string & {});
|
|
379
|
+
/** Given to a new deployment's default locale. */
|
|
380
|
+
isDefault: boolean;
|
|
381
|
+
isActive: boolean;
|
|
382
|
+
createdBy: string | null;
|
|
383
|
+
updatedBy: string | null;
|
|
384
|
+
createdAt: string;
|
|
385
|
+
updatedAt: string;
|
|
386
|
+
}
|
|
387
|
+
export interface CreateLanguageInput {
|
|
388
|
+
code: string;
|
|
389
|
+
name: string;
|
|
390
|
+
nativeName: string;
|
|
391
|
+
/** Defaults to "ltr" when creating. */
|
|
392
|
+
direction?: "ltr" | "rtl";
|
|
393
|
+
isDefault?: boolean;
|
|
394
|
+
isActive?: boolean;
|
|
395
|
+
}
|
|
396
|
+
export interface UpdateLanguageInput {
|
|
397
|
+
code?: string;
|
|
398
|
+
name?: string;
|
|
399
|
+
nativeName?: string;
|
|
400
|
+
direction?: "ltr" | "rtl";
|
|
401
|
+
isDefault?: boolean;
|
|
402
|
+
isActive?: boolean;
|
|
403
|
+
}
|
|
404
|
+
export interface LanguageListFilter {
|
|
405
|
+
search?: string;
|
|
406
|
+
/** 1-indexed. Defaults to 1. */
|
|
407
|
+
page?: number;
|
|
408
|
+
/** Defaults to 25, capped at 100. */
|
|
409
|
+
limit?: number;
|
|
410
|
+
/** Pass true for a picker/dropdown — omitted returns everything, active or not. */
|
|
411
|
+
activeOnly?: boolean;
|
|
412
|
+
}
|
|
413
|
+
export interface LanguageListResult {
|
|
414
|
+
items: LanguageSummary[];
|
|
415
|
+
meta: PageMeta;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* End-users managed by admins — no login capability, and not related to the RBAC `UserSummary`
|
|
419
|
+
* above. Every column the `Customer` table has — there is no login credential to withhold.
|
|
420
|
+
*/
|
|
421
|
+
export interface CustomerSummary {
|
|
422
|
+
id: string;
|
|
423
|
+
uuid: string;
|
|
424
|
+
firstName: string | null;
|
|
425
|
+
lastName: string | null;
|
|
426
|
+
/** Unique across the deployment. */
|
|
427
|
+
username: string | null;
|
|
428
|
+
email: string;
|
|
429
|
+
/** Unique across the deployment. */
|
|
430
|
+
phone: string | null;
|
|
431
|
+
dob: string | null;
|
|
432
|
+
gender: string | null;
|
|
433
|
+
joinedDate: string;
|
|
434
|
+
/** A data URI or an externally-hosted URL — stored as-is, never processed server-side. */
|
|
435
|
+
photo: string | null;
|
|
436
|
+
isEmailVerified: boolean;
|
|
437
|
+
isPhoneVerified: boolean;
|
|
438
|
+
isActive: boolean;
|
|
439
|
+
createdBy: string | null;
|
|
440
|
+
updatedBy: string | null;
|
|
441
|
+
createdAt: string;
|
|
442
|
+
updatedAt: string;
|
|
443
|
+
}
|
|
444
|
+
export interface CreateCustomerInput {
|
|
445
|
+
email: string;
|
|
446
|
+
firstName?: string;
|
|
447
|
+
lastName?: string;
|
|
448
|
+
username?: string;
|
|
449
|
+
phone?: string;
|
|
450
|
+
dob?: string;
|
|
451
|
+
gender?: string;
|
|
452
|
+
/** Defaults to today when creating. */
|
|
453
|
+
joinedDate?: string;
|
|
454
|
+
photo?: string;
|
|
455
|
+
isEmailVerified?: boolean;
|
|
456
|
+
isPhoneVerified?: boolean;
|
|
457
|
+
isActive?: boolean;
|
|
458
|
+
}
|
|
459
|
+
export interface UpdateCustomerInput {
|
|
460
|
+
email?: string;
|
|
461
|
+
firstName?: string | null;
|
|
462
|
+
lastName?: string | null;
|
|
463
|
+
username?: string | null;
|
|
464
|
+
phone?: string | null;
|
|
465
|
+
dob?: string | null;
|
|
466
|
+
gender?: string | null;
|
|
467
|
+
joinedDate?: string;
|
|
468
|
+
photo?: string | null;
|
|
469
|
+
isEmailVerified?: boolean;
|
|
470
|
+
isPhoneVerified?: boolean;
|
|
471
|
+
isActive?: boolean;
|
|
472
|
+
}
|
|
473
|
+
export interface CustomerListFilter {
|
|
474
|
+
search?: string;
|
|
475
|
+
/** 1-indexed. Defaults to 1. */
|
|
476
|
+
page?: number;
|
|
477
|
+
/** Defaults to 25, capped at 100. */
|
|
478
|
+
limit?: number;
|
|
479
|
+
/** Pass true for a picker/dropdown — omitted returns everything, active or not. */
|
|
480
|
+
activeOnly?: boolean;
|
|
481
|
+
}
|
|
482
|
+
export interface CustomerListResult {
|
|
483
|
+
items: CustomerSummary[];
|
|
484
|
+
meta: PageMeta;
|
|
485
|
+
}
|
|
486
|
+
export declare class AuthApiError extends Error {
|
|
487
|
+
readonly status: number;
|
|
488
|
+
readonly code?: string | undefined;
|
|
489
|
+
constructor(message: string, status: number, code?: string | undefined);
|
|
490
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function userIdOf(user) {
|
|
2
|
+
return "userId" in user ? user.userId : user.id;
|
|
3
|
+
}
|
|
4
|
+
export class AuthApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
code;
|
|
7
|
+
constructor(message, status, code) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = "AuthApiError";
|
|
12
|
+
}
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-auth-kit/auth-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared, framework-agnostic auth API client for the easy-auth backend. Used by all 4 client apps (admin-nextjs, admin-react, mobile-expo, mobile-bare-rn) — the auth flow and API contract are identical across them, only the injected TokenStorage adapter differs per platform (cookies-next on web, AsyncStorage on mobile).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.build.json",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "vitest run"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/node": "^26.2.0",
|
|
16
|
+
"typescript": "^7.0.2",
|
|
17
|
+
"vitest": "^4.1.10"
|
|
18
|
+
}
|
|
19
|
+
}
|