@simple-auth-kit/cli 1.3.1 → 1.4.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/README.md +6 -5
- package/package.json +1 -1
- package/registry/admin-apps/nextjs/shared/.husky/commit-msg +1 -0
- package/registry/admin-apps/nextjs/shared/.husky/pre-commit +1 -0
- package/registry/admin-apps/nextjs/shared/.lintstagedrc.json +4 -0
- package/registry/admin-apps/nextjs/shared/.prettierrc.json +3 -0
- package/registry/admin-apps/nextjs/shared/commitlint.config.js +5 -0
- package/registry/admin-apps/nextjs/shared/eslint.config.js +28 -0
- package/registry/admin-apps/nextjs/variants/base/package.json +11 -2
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/audit-log/page.tsx +72 -16
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/customers/[id]/edit/page.tsx +107 -26
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/customers/[id]/page.tsx +131 -29
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/languages/[id]/edit/page.tsx +111 -25
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/languages/[id]/page.tsx +97 -22
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/permissions/[id]/edit/page.tsx +129 -46
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/permissions/[id]/page.tsx +64 -14
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/roles/[id]/page.tsx +91 -22
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/users/[id]/edit/page.tsx +94 -23
- package/registry/admin-apps/nextjs/variants/workspaces/package.json +11 -2
- package/registry/admin-apps/nextjs/variants/workspaces/src/app/(console)/account/page.tsx +179 -41
- package/registry/admin-apps/react/shared/.husky/commit-msg +1 -0
- package/registry/admin-apps/react/shared/.husky/pre-commit +1 -0
- package/registry/admin-apps/react/shared/.lintstagedrc.json +4 -0
- package/registry/admin-apps/react/shared/.prettierrc.json +3 -0
- package/registry/admin-apps/react/shared/commitlint.config.js +3 -0
- package/registry/admin-apps/react/shared/eslint.config.js +38 -0
- package/registry/admin-apps/react/variants/base/package.json +15 -2
- package/registry/admin-apps/react/variants/base/src/pages/CountryDetailPage.tsx +76 -19
- package/registry/admin-apps/react/variants/base/src/pages/CustomerDetailPage.tsx +110 -26
- package/registry/admin-apps/react/variants/base/src/pages/EditCountryPage.tsx +115 -29
- package/registry/admin-apps/react/variants/base/src/pages/EditCustomerPage.tsx +123 -31
- package/registry/admin-apps/react/variants/base/src/pages/EditLanguagePage.tsx +100 -24
- package/registry/admin-apps/react/variants/base/src/pages/EditRolePage.tsx +99 -26
- package/registry/admin-apps/react/variants/base/src/pages/EditUserPage.tsx +126 -31
- package/registry/admin-apps/react/variants/base/src/pages/LanguageDetailPage.tsx +75 -19
- package/registry/admin-apps/react/variants/base/src/pages/PermissionDetailPage.tsx +58 -13
- package/registry/admin-apps/react/variants/base/src/pages/PermissionsPage.tsx +132 -29
- package/registry/admin-apps/react/variants/base/src/pages/RoleDetailPage.tsx +70 -17
- package/registry/admin-apps/react/variants/base/src/pages/RolesPage.tsx +277 -59
- package/registry/admin-apps/react/variants/workspaces/package.json +15 -2
- package/registry/combos/express-drizzle/eslint.config.js +18 -0
- package/registry/combos/express-drizzle/package.json +5 -1
- package/registry/combos/express-drizzle/shared/src/request-context.ts +3 -0
- package/registry/combos/express-prisma/eslint.config.js +18 -0
- package/registry/combos/express-prisma/package.json +5 -1
- package/registry/combos/express-prisma/shared/src/request-context.ts +3 -0
- package/registry/combos/nestjs-drizzle/eslint.config.js +18 -0
- package/registry/combos/nestjs-drizzle/package.json +5 -1
- package/registry/combos/nestjs-drizzle/shared/src/auth.controller.ts +138 -33
- package/registry/combos/nestjs-drizzle/shared/src/route-tiers.ts +71 -18
- package/registry/combos/nestjs-prisma/eslint.config.js +18 -0
- package/registry/combos/nestjs-prisma/package.json +5 -1
- package/registry/combos/nestjs-prisma/shared/src/auth.controller.ts +148 -35
- package/registry/combos/nestjs-prisma/shared/src/route-tiers.ts +72 -18
- package/registry/mobile-apps/bare-rn/shared/.husky/commit-msg +1 -0
- package/registry/mobile-apps/bare-rn/shared/.husky/pre-commit +1 -0
- package/registry/mobile-apps/bare-rn/shared/.lintstagedrc.json +4 -0
- package/registry/mobile-apps/bare-rn/shared/.prettierrc.json +5 -0
- package/registry/mobile-apps/bare-rn/shared/commitlint.config.js +3 -0
- package/registry/mobile-apps/bare-rn/shared/eslint.config.js +19 -0
- package/registry/mobile-apps/bare-rn/variants/base/package.json +17 -12
- package/registry/mobile-apps/bare-rn/variants/workspaces/package.json +17 -12
- package/registry/mobile-apps/expo/shared/.husky/commit-msg +1 -0
- package/registry/mobile-apps/expo/shared/.husky/pre-commit +1 -0
- package/registry/mobile-apps/expo/shared/.lintstagedrc.json +4 -0
- package/registry/mobile-apps/expo/shared/.prettierrc.json +5 -0
- package/registry/mobile-apps/expo/shared/commitlint.config.js +3 -0
- package/registry/mobile-apps/expo/shared/eslint.config.js +9 -0
- package/registry/mobile-apps/expo/shared/src/screens/SessionsScreen.tsx +6 -17
- package/registry/mobile-apps/expo/shared/src/store/sessionsStore.ts +35 -0
- package/registry/mobile-apps/expo/variants/base/package.json +12 -3
- package/registry/mobile-apps/expo/variants/workspaces/package.json +12 -3
- package/registry/mobile-apps/expo/variants/workspaces/src/screens/WorkspaceSelectScreen.tsx +1 -1
- package/registry.json +13 -36
- package/simple-auth-kit.ts +13 -10
- package/registry/mobile-apps/bare-rn/shared/.eslintrc.js +0 -4
- package/registry/mobile-apps/bare-rn/shared/.prettierrc.js +0 -5
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"scripts": {
|
|
8
8
|
"materialize": "node scripts/materialize.mjs",
|
|
9
9
|
"typecheck": "node scripts/materialize.mjs --all && tsc -p .variant/base && tsc -p .variant/workspaces",
|
|
10
|
+
"lint": "eslint shared variants",
|
|
10
11
|
"prove-cycle": "node scripts/prove-cycle.mjs",
|
|
11
12
|
"migrate": "node scripts/migrate.mjs",
|
|
12
13
|
"seed": "node scripts/seed.mjs"
|
|
@@ -19,14 +20,17 @@
|
|
|
19
20
|
"swagger-ui-express": "^5.0.1"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
23
|
+
"@eslint/js": "^9.39.5",
|
|
22
24
|
"@types/express": "^5.0.6",
|
|
23
25
|
"@types/node": "^26.2.0",
|
|
24
26
|
"@types/pg": "^8.21.0",
|
|
25
27
|
"@types/swagger-ui-express": "^4.1.8",
|
|
26
28
|
"dotenv": "^17.4.2",
|
|
27
29
|
"drizzle-kit": "^0.31.10",
|
|
30
|
+
"eslint": "^9.39.5",
|
|
28
31
|
"tsx": "^4.23.11",
|
|
29
|
-
"typescript": "^
|
|
32
|
+
"typescript": "^6.0.3",
|
|
33
|
+
"typescript-eslint": "^8.70.0",
|
|
30
34
|
"vitest": "^4.1.10"
|
|
31
35
|
}
|
|
32
36
|
}
|
|
@@ -23,6 +23,9 @@ import type { AppAbility } from "./ability.js";
|
|
|
23
23
|
import type { AuthzContext } from "./authz.middleware.js";
|
|
24
24
|
|
|
25
25
|
declare global {
|
|
26
|
+
// Express's own declaration-merging style for augmenting its Request type requires a
|
|
27
|
+
// `namespace` here; there's no ES module form.
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
26
29
|
namespace Express {
|
|
27
30
|
interface Request {
|
|
28
31
|
auth?: AccessTokenClaims;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import tseslint from "typescript-eslint";
|
|
3
|
+
|
|
4
|
+
export default tseslint.config(
|
|
5
|
+
{ ignores: [".variant/**", "node_modules/**", "dist/**", "generated/**"] },
|
|
6
|
+
js.configs.recommended,
|
|
7
|
+
...tseslint.configs.recommended,
|
|
8
|
+
{
|
|
9
|
+
rules: {
|
|
10
|
+
// This combo's leading-underscore convention (e.g. an interface-required parameter that's
|
|
11
|
+
// deliberately unused) predates this eslint config — respect it rather than flag it.
|
|
12
|
+
"@typescript-eslint/no-unused-vars": [
|
|
13
|
+
"error",
|
|
14
|
+
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"materialize": "node scripts/materialize.mjs",
|
|
9
9
|
"prisma:generate": "node scripts/materialize.mjs --all --generate",
|
|
10
10
|
"typecheck": "npm run prisma:generate && tsc -p .variant/base && tsc -p .variant/workspaces",
|
|
11
|
+
"lint": "eslint shared variants",
|
|
11
12
|
"prove-cycle": "node scripts/prove-cycle.mjs",
|
|
12
13
|
"migrate": "node scripts/migrate.mjs",
|
|
13
14
|
"seed": "node scripts/seed.mjs"
|
|
@@ -20,13 +21,16 @@
|
|
|
20
21
|
"swagger-ui-express": "^5.0.1"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
24
|
+
"@eslint/js": "^9.39.5",
|
|
23
25
|
"@types/express": "^5.0.6",
|
|
24
26
|
"@types/node": "^26.2.0",
|
|
25
27
|
"@types/swagger-ui-express": "^4.1.8",
|
|
26
28
|
"dotenv": "^17.4.2",
|
|
29
|
+
"eslint": "^9.39.5",
|
|
27
30
|
"prisma": "^7.9.1",
|
|
28
31
|
"tsx": "^4.23.11",
|
|
29
|
-
"typescript": "^
|
|
32
|
+
"typescript": "^6.0.3",
|
|
33
|
+
"typescript-eslint": "^8.70.0",
|
|
30
34
|
"vitest": "^4.1.10"
|
|
31
35
|
}
|
|
32
36
|
}
|
|
@@ -23,6 +23,9 @@ import type { AppAbility } from "./ability.js";
|
|
|
23
23
|
import type { AuthzContext } from "./authz.middleware.js";
|
|
24
24
|
|
|
25
25
|
declare global {
|
|
26
|
+
// Express's own declaration-merging style for augmenting its Request type requires a
|
|
27
|
+
// `namespace` here; there's no ES module form.
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
26
29
|
namespace Express {
|
|
27
30
|
interface Request {
|
|
28
31
|
auth?: AccessTokenClaims;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import tseslint from "typescript-eslint";
|
|
3
|
+
|
|
4
|
+
export default tseslint.config(
|
|
5
|
+
{ ignores: [".variant/**", "node_modules/**", "dist/**", "generated/**"] },
|
|
6
|
+
js.configs.recommended,
|
|
7
|
+
...tseslint.configs.recommended,
|
|
8
|
+
{
|
|
9
|
+
rules: {
|
|
10
|
+
// This combo's leading-underscore convention (e.g. an interface-required parameter that's
|
|
11
|
+
// deliberately unused) predates this eslint config — respect it rather than flag it.
|
|
12
|
+
"@typescript-eslint/no-unused-vars": [
|
|
13
|
+
"error",
|
|
14
|
+
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"scripts": {
|
|
8
8
|
"materialize": "node scripts/materialize.mjs",
|
|
9
9
|
"typecheck": "node scripts/materialize.mjs --all && tsc -p .variant/base && tsc -p .variant/workspaces",
|
|
10
|
+
"lint": "eslint shared variants",
|
|
10
11
|
"prove-cycle": "node scripts/prove-cycle.mjs",
|
|
11
12
|
"migrate": "node scripts/migrate.mjs",
|
|
12
13
|
"seed": "node scripts/seed.mjs"
|
|
@@ -24,13 +25,16 @@
|
|
|
24
25
|
"rxjs": "^7.8.2"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
|
28
|
+
"@eslint/js": "^9.39.5",
|
|
27
29
|
"@types/express": "^5.0.6",
|
|
28
30
|
"@types/node": "^26.2.0",
|
|
29
31
|
"@types/pg": "^8.21.0",
|
|
30
32
|
"dotenv": "^17.4.2",
|
|
31
33
|
"drizzle-kit": "^0.31.10",
|
|
34
|
+
"eslint": "^9.39.5",
|
|
32
35
|
"tsx": "^4.23.11",
|
|
33
|
-
"typescript": "^
|
|
36
|
+
"typescript": "^6.0.3",
|
|
37
|
+
"typescript-eslint": "^8.70.0",
|
|
34
38
|
"vitest": "^4.1.10"
|
|
35
39
|
}
|
|
36
40
|
}
|
|
@@ -1,5 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
BadRequestException,
|
|
3
|
+
Body,
|
|
4
|
+
Controller,
|
|
5
|
+
Get,
|
|
6
|
+
Inject,
|
|
7
|
+
Ip,
|
|
8
|
+
Param,
|
|
9
|
+
Patch,
|
|
10
|
+
Post,
|
|
11
|
+
Query,
|
|
12
|
+
Req,
|
|
13
|
+
UseGuards,
|
|
14
|
+
} from "@nestjs/common";
|
|
15
|
+
import {
|
|
16
|
+
ApiBearerAuth,
|
|
17
|
+
ApiBody,
|
|
18
|
+
ApiOperation,
|
|
19
|
+
ApiParam,
|
|
20
|
+
ApiQuery,
|
|
21
|
+
ApiResponse,
|
|
22
|
+
ApiTags,
|
|
23
|
+
} from "@nestjs/swagger";
|
|
3
24
|
import type { Request } from "express";
|
|
4
25
|
import { AUTH_CONFIG, AuthConfig } from "./auth.config.js";
|
|
5
26
|
import { AuthGuard } from "./auth.guard.js";
|
|
@@ -23,17 +44,18 @@ import {
|
|
|
23
44
|
SelfProfileDto,
|
|
24
45
|
SessionSummaryDto,
|
|
25
46
|
SignupDto,
|
|
26
|
-
TwoFactorChallengeDto,
|
|
27
47
|
TwoFactorCodeDto,
|
|
28
48
|
UpdateUserDto,
|
|
29
49
|
} from "./dto/auth.dto.js";
|
|
30
50
|
|
|
31
51
|
function requireString(value: unknown, field: string): string {
|
|
32
|
-
if (typeof value !== "string" || value.length === 0)
|
|
52
|
+
if (typeof value !== "string" || value.length === 0)
|
|
53
|
+
throw new BadRequestException(`${field} is required`);
|
|
33
54
|
return value;
|
|
34
55
|
}
|
|
35
56
|
|
|
36
|
-
const optionalString = (value: unknown): string | undefined =>
|
|
57
|
+
const optionalString = (value: unknown): string | undefined =>
|
|
58
|
+
typeof value === "string" ? value : undefined;
|
|
37
59
|
|
|
38
60
|
/**
|
|
39
61
|
* Identity endpoints — everything that is about *a user*, never about a group of them.
|
|
@@ -52,7 +74,11 @@ export class AuthController {
|
|
|
52
74
|
@ApiOperation({ summary: "Create a user and issue a session" })
|
|
53
75
|
@ApiBody({ type: SignupDto })
|
|
54
76
|
@ApiResponse({ status: 201, type: AuthTokensDto })
|
|
55
|
-
async signup(
|
|
77
|
+
async signup(
|
|
78
|
+
@Body() body: Record<string, unknown>,
|
|
79
|
+
@Req() req: Request,
|
|
80
|
+
@Ip() ip: string,
|
|
81
|
+
) {
|
|
56
82
|
return this.auth.signup({
|
|
57
83
|
email: requireString(body.email, "email"),
|
|
58
84
|
password: requireString(body.password, "password"),
|
|
@@ -70,11 +96,24 @@ export class AuthController {
|
|
|
70
96
|
@Public()
|
|
71
97
|
@ApiOperation({
|
|
72
98
|
summary: "Log in with email, username, or phone + password",
|
|
73
|
-
description:
|
|
99
|
+
description:
|
|
100
|
+
"identifier is matched against email, username, and phone, in that order. Returns tokens directly, or a 2FA challenge if the account has 2FA enabled.",
|
|
74
101
|
})
|
|
75
102
|
@ApiBody({ type: LoginDto })
|
|
76
|
-
@ApiResponse({
|
|
77
|
-
|
|
103
|
+
@ApiResponse({
|
|
104
|
+
status: 201,
|
|
105
|
+
schema: {
|
|
106
|
+
oneOf: [
|
|
107
|
+
{ $ref: "#/components/schemas/AuthTokensDto" },
|
|
108
|
+
{ $ref: "#/components/schemas/TwoFactorChallengeDto" },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
async login(
|
|
113
|
+
@Body() body: Record<string, unknown>,
|
|
114
|
+
@Req() req: Request,
|
|
115
|
+
@Ip() ip: string,
|
|
116
|
+
) {
|
|
78
117
|
return this.auth.login({
|
|
79
118
|
identifier: requireString(body.identifier, "identifier"),
|
|
80
119
|
password: requireString(body.password, "password"),
|
|
@@ -88,7 +127,11 @@ export class AuthController {
|
|
|
88
127
|
@ApiOperation({ summary: "Complete the 2FA challenge from POST /auth/login" })
|
|
89
128
|
@ApiBody({ type: LoginTwoFactorDto })
|
|
90
129
|
@ApiResponse({ status: 201, type: AuthTokensDto })
|
|
91
|
-
async loginTwoFactor(
|
|
130
|
+
async loginTwoFactor(
|
|
131
|
+
@Body() body: Record<string, unknown>,
|
|
132
|
+
@Req() req: Request,
|
|
133
|
+
@Ip() ip: string,
|
|
134
|
+
) {
|
|
92
135
|
return this.auth.loginTwoFactor({
|
|
93
136
|
challengeToken: requireString(body.challengeToken, "challengeToken"),
|
|
94
137
|
code: requireString(body.code, "code"),
|
|
@@ -99,7 +142,9 @@ export class AuthController {
|
|
|
99
142
|
|
|
100
143
|
@Post("refresh")
|
|
101
144
|
@Public()
|
|
102
|
-
@ApiOperation({
|
|
145
|
+
@ApiOperation({
|
|
146
|
+
summary: "Rotate a refresh token for a new access+refresh pair",
|
|
147
|
+
})
|
|
103
148
|
@ApiBody({ type: RefreshDto })
|
|
104
149
|
@ApiResponse({ status: 201, type: RefreshResponseDto })
|
|
105
150
|
async refresh(@Body() body: Record<string, unknown>) {
|
|
@@ -121,7 +166,10 @@ export class AuthController {
|
|
|
121
166
|
@ApiResponse({ status: 200, type: CurrentUserDto })
|
|
122
167
|
async me(@Req() req: Request) {
|
|
123
168
|
const { sub, sessionId } = req.auth!;
|
|
124
|
-
const [{ twoFactorEnabled }, profile] = await Promise.all([
|
|
169
|
+
const [{ twoFactorEnabled }, profile] = await Promise.all([
|
|
170
|
+
this.auth.getTwoFactorStatus(sub),
|
|
171
|
+
this.auth.getProfile(sub),
|
|
172
|
+
]);
|
|
125
173
|
// The same `req.authz` AbilityGuard's ability was built from — not re-resolved here.
|
|
126
174
|
return {
|
|
127
175
|
sub,
|
|
@@ -143,7 +191,9 @@ export class AuthController {
|
|
|
143
191
|
@Authenticated()
|
|
144
192
|
@ApiBearerAuth()
|
|
145
193
|
@UseGuards(AuthGuard)
|
|
146
|
-
@ApiOperation({
|
|
194
|
+
@ApiOperation({
|
|
195
|
+
summary: "List the current user's active (non-revoked) sessions",
|
|
196
|
+
})
|
|
147
197
|
@ApiResponse({ status: 200, type: [SessionSummaryDto] })
|
|
148
198
|
async sessions(@Req() req: Request) {
|
|
149
199
|
return this.auth.listActiveSessions(req.auth!.sub);
|
|
@@ -159,7 +209,10 @@ export class AuthController {
|
|
|
159
209
|
const { sessionId, jti, sub } = req.auth!;
|
|
160
210
|
// The revoker is the caller themselves. Recorded so the row can later be told apart from a
|
|
161
211
|
// session an administrator ended — see `sessions.revoked_by`.
|
|
162
|
-
await this.auth.logout(sessionId, jti, this.config.accessTokenTtlSeconds, {
|
|
212
|
+
await this.auth.logout(sessionId, jti, this.config.accessTokenTtlSeconds, {
|
|
213
|
+
userId: sub,
|
|
214
|
+
ip,
|
|
215
|
+
});
|
|
163
216
|
return { ok: true };
|
|
164
217
|
}
|
|
165
218
|
|
|
@@ -181,7 +234,10 @@ export class AuthController {
|
|
|
181
234
|
@ApiOperation({ summary: "Revoke every session except the current one" })
|
|
182
235
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
183
236
|
async logoutOthers(@Req() req: Request, @Ip() ip: string) {
|
|
184
|
-
await this.auth.logoutOthers(req.auth!.sub, req.auth!.sessionId, {
|
|
237
|
+
await this.auth.logoutOthers(req.auth!.sub, req.auth!.sessionId, {
|
|
238
|
+
userId: req.auth!.sub,
|
|
239
|
+
ip,
|
|
240
|
+
});
|
|
185
241
|
return { ok: true };
|
|
186
242
|
}
|
|
187
243
|
|
|
@@ -191,11 +247,15 @@ export class AuthController {
|
|
|
191
247
|
@UseGuards(AuthGuard)
|
|
192
248
|
@ApiOperation({
|
|
193
249
|
summary: "Change the current password",
|
|
194
|
-
description:
|
|
250
|
+
description:
|
|
251
|
+
"Requires the current password. Every other session is revoked; the one making this call is left alone.",
|
|
195
252
|
})
|
|
196
253
|
@ApiBody({ type: ChangePasswordDto })
|
|
197
254
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
198
|
-
async changePassword(
|
|
255
|
+
async changePassword(
|
|
256
|
+
@Body() body: Record<string, unknown>,
|
|
257
|
+
@Req() req: Request,
|
|
258
|
+
) {
|
|
199
259
|
await this.auth.changePassword(req.auth!.sub, req.auth!.sessionId, {
|
|
200
260
|
currentPassword: requireString(body.currentPassword, "currentPassword"),
|
|
201
261
|
newPassword: requireString(body.newPassword, "newPassword"),
|
|
@@ -209,15 +269,18 @@ export class AuthController {
|
|
|
209
269
|
@UseGuards(AuthGuard)
|
|
210
270
|
@ApiOperation({
|
|
211
271
|
summary: "Update the current user's own profile",
|
|
212
|
-
description:
|
|
272
|
+
description:
|
|
273
|
+
"Self-service — no admin permission required. Updates the caller's own row directly; unrelated to any workspace.",
|
|
213
274
|
})
|
|
214
275
|
@ApiBody({ type: UpdateUserDto })
|
|
215
276
|
@ApiResponse({ status: 200, type: SelfProfileDto })
|
|
216
277
|
async updateMe(@Body() body: Record<string, unknown>, @Req() req: Request) {
|
|
217
278
|
return this.auth.updateProfile(req.auth!.sub, {
|
|
218
|
-
firstName:
|
|
279
|
+
firstName:
|
|
280
|
+
body.firstName === null ? null : optionalString(body.firstName),
|
|
219
281
|
lastName: body.lastName === null ? null : optionalString(body.lastName),
|
|
220
|
-
displayName:
|
|
282
|
+
displayName:
|
|
283
|
+
body.displayName === null ? null : optionalString(body.displayName),
|
|
221
284
|
phone: body.phone === null ? null : optionalString(body.phone),
|
|
222
285
|
username: body.username === null ? null : optionalString(body.username),
|
|
223
286
|
photo: body.photo === null ? null : optionalString(body.photo),
|
|
@@ -228,7 +291,10 @@ export class AuthController {
|
|
|
228
291
|
@Authenticated()
|
|
229
292
|
@ApiBearerAuth()
|
|
230
293
|
@UseGuards(AuthGuard)
|
|
231
|
-
@ApiOperation({
|
|
294
|
+
@ApiOperation({
|
|
295
|
+
summary: "Begin TOTP enrollment",
|
|
296
|
+
description: "Not active until confirmed via POST /auth/2fa/confirm.",
|
|
297
|
+
})
|
|
232
298
|
@ApiResponse({ status: 201, type: EnrollTwoFactorResponseDto })
|
|
233
299
|
async enrollTwoFactor(@Req() req: Request) {
|
|
234
300
|
return this.auth.enrollTwoFactor(req.auth!.sub);
|
|
@@ -238,28 +304,51 @@ export class AuthController {
|
|
|
238
304
|
@Authenticated()
|
|
239
305
|
@ApiBearerAuth()
|
|
240
306
|
@UseGuards(AuthGuard)
|
|
241
|
-
@ApiOperation({
|
|
307
|
+
@ApiOperation({
|
|
308
|
+
summary: "Confirm TOTP enrollment with a code",
|
|
309
|
+
description:
|
|
310
|
+
"Enables 2FA and returns one-time backup codes, shown only here.",
|
|
311
|
+
})
|
|
242
312
|
@ApiBody({ type: TwoFactorCodeDto })
|
|
243
313
|
@ApiResponse({ status: 201, type: ConfirmTwoFactorResponseDto })
|
|
244
|
-
async confirmTwoFactor(
|
|
245
|
-
|
|
314
|
+
async confirmTwoFactor(
|
|
315
|
+
@Body() body: Record<string, unknown>,
|
|
316
|
+
@Req() req: Request,
|
|
317
|
+
) {
|
|
318
|
+
return this.auth.confirmTwoFactor(
|
|
319
|
+
req.auth!.sub,
|
|
320
|
+
requireString(body.code, "code"),
|
|
321
|
+
);
|
|
246
322
|
}
|
|
247
323
|
|
|
248
324
|
@Post("2fa/disable")
|
|
249
325
|
@Authenticated()
|
|
250
326
|
@ApiBearerAuth()
|
|
251
327
|
@UseGuards(AuthGuard)
|
|
252
|
-
@ApiOperation({
|
|
328
|
+
@ApiOperation({
|
|
329
|
+
summary: "Disable 2FA",
|
|
330
|
+
description: "Requires a valid TOTP or backup code.",
|
|
331
|
+
})
|
|
253
332
|
@ApiBody({ type: TwoFactorCodeDto })
|
|
254
333
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
255
|
-
async disableTwoFactor(
|
|
256
|
-
|
|
334
|
+
async disableTwoFactor(
|
|
335
|
+
@Body() body: Record<string, unknown>,
|
|
336
|
+
@Req() req: Request,
|
|
337
|
+
) {
|
|
338
|
+
await this.auth.disableTwoFactor(
|
|
339
|
+
req.auth!.sub,
|
|
340
|
+
requireString(body.code, "code"),
|
|
341
|
+
);
|
|
257
342
|
return { ok: true };
|
|
258
343
|
}
|
|
259
344
|
|
|
260
345
|
@Post("password/forgot")
|
|
261
346
|
@Public()
|
|
262
|
-
@ApiOperation({
|
|
347
|
+
@ApiOperation({
|
|
348
|
+
summary: "Request a password reset",
|
|
349
|
+
description:
|
|
350
|
+
"Always reports success, whether or not the email exists — avoids account enumeration.",
|
|
351
|
+
})
|
|
263
352
|
@ApiBody({ type: ForgotPasswordDto })
|
|
264
353
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
265
354
|
async forgotPassword(@Body() body: Record<string, unknown>) {
|
|
@@ -269,11 +358,17 @@ export class AuthController {
|
|
|
269
358
|
|
|
270
359
|
@Post("password/reset")
|
|
271
360
|
@Public()
|
|
272
|
-
@ApiOperation({
|
|
361
|
+
@ApiOperation({
|
|
362
|
+
summary: "Complete a password reset",
|
|
363
|
+
description: "Revokes every existing session for the user on success.",
|
|
364
|
+
})
|
|
273
365
|
@ApiBody({ type: ResetPasswordDto })
|
|
274
366
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
275
367
|
async resetPassword(@Body() body: Record<string, unknown>) {
|
|
276
|
-
await this.auth.resetPassword(
|
|
368
|
+
await this.auth.resetPassword(
|
|
369
|
+
requireString(body.token, "token"),
|
|
370
|
+
requireString(body.newPassword, "newPassword"),
|
|
371
|
+
);
|
|
277
372
|
return { ok: true };
|
|
278
373
|
}
|
|
279
374
|
|
|
@@ -288,12 +383,22 @@ export class AuthController {
|
|
|
288
383
|
|
|
289
384
|
@Get("oauth/:provider/callback")
|
|
290
385
|
@Public()
|
|
291
|
-
@ApiOperation({
|
|
386
|
+
@ApiOperation({
|
|
387
|
+
summary: "OAuth provider callback — exchanges the code and completes login",
|
|
388
|
+
})
|
|
292
389
|
@ApiParam({ name: "provider", enum: ["google", "apple"] })
|
|
293
390
|
@ApiQuery({ name: "code", required: true })
|
|
294
391
|
@ApiQuery({ name: "state", required: true })
|
|
295
392
|
@ApiResponse({ status: 200, type: AuthTokensDto })
|
|
296
|
-
async oauthCallback(
|
|
297
|
-
|
|
393
|
+
async oauthCallback(
|
|
394
|
+
@Param("provider") provider: string,
|
|
395
|
+
@Query("code") code: string,
|
|
396
|
+
@Query("state") state: string,
|
|
397
|
+
) {
|
|
398
|
+
return this.auth.completeOAuthCallback(
|
|
399
|
+
provider,
|
|
400
|
+
requireString(code, "code"),
|
|
401
|
+
requireString(state, "state"),
|
|
402
|
+
);
|
|
298
403
|
}
|
|
299
404
|
}
|
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
// with no AuthGuard, or @CheckAbility(...) with no AbilityGuard, is the same class of mistake as
|
|
19
19
|
// declaring nothing at all, and would otherwise be an open route that looks closed in the source.
|
|
20
20
|
import { RequestMethod, SetMetadata } from "@nestjs/common";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
GUARDS_METADATA,
|
|
23
|
+
METHOD_METADATA,
|
|
24
|
+
PATH_METADATA,
|
|
25
|
+
} from "@nestjs/common/constants.js";
|
|
22
26
|
import type { PermissionSlug } from "./rbac.defaults.js";
|
|
23
27
|
|
|
24
28
|
export const ROUTE_TIER_KEY = "routeTier";
|
|
@@ -32,14 +36,16 @@ export type RouteTier = "public" | "authenticated" | "ability";
|
|
|
32
36
|
* said out loud — "this route is open" must be a decision someone wrote down, not the absence of
|
|
33
37
|
* one.
|
|
34
38
|
*/
|
|
35
|
-
export const Public = () =>
|
|
39
|
+
export const Public = () =>
|
|
40
|
+
SetMetadata(ROUTE_TIER_KEY, "public" satisfies RouteTier);
|
|
36
41
|
|
|
37
42
|
/**
|
|
38
43
|
* Tier 2: any authenticated caller, no permission required. Pair with `@UseGuards(AuthGuard)`
|
|
39
44
|
* (plus the variant's authorization guard where the handler reads `req.authz`) — the startup
|
|
40
45
|
* check refuses a route that claims this tier without an authentication guard behind it.
|
|
41
46
|
*/
|
|
42
|
-
export const Authenticated = () =>
|
|
47
|
+
export const Authenticated = () =>
|
|
48
|
+
SetMetadata(ROUTE_TIER_KEY, "authenticated" satisfies RouteTier);
|
|
43
49
|
|
|
44
50
|
/**
|
|
45
51
|
* Tier 3: `@CheckAbility("users:block")`. Requires `AuthGuard`, then the variant's authorization
|
|
@@ -53,7 +59,10 @@ export const Authenticated = () => SetMetadata(ROUTE_TIER_KEY, "authenticated" s
|
|
|
53
59
|
* explain. Which *users* hold the slug is entirely a matter of rows.
|
|
54
60
|
*/
|
|
55
61
|
export const CheckAbility = (...abilities: PermissionSlug[]) =>
|
|
56
|
-
applyBoth(
|
|
62
|
+
applyBoth(
|
|
63
|
+
SetMetadata(ROUTE_TIER_KEY, "ability" satisfies RouteTier),
|
|
64
|
+
SetMetadata(CHECK_ABILITY_KEY, abilities as string[]),
|
|
65
|
+
);
|
|
57
66
|
|
|
58
67
|
/** `applyDecorators` from @nestjs/common, minus its class-decorator branch — these two are method decorators. */
|
|
59
68
|
function applyBoth(...decorators: MethodDecorator[]): MethodDecorator {
|
|
@@ -63,11 +72,26 @@ function applyBoth(...decorators: MethodDecorator[]): MethodDecorator {
|
|
|
63
72
|
};
|
|
64
73
|
}
|
|
65
74
|
|
|
75
|
+
// `any` here is real type erasure, not laziness: controllers/guards have heterogeneous
|
|
76
|
+
// constructor signatures, and these types only need to carry a class/method identity through
|
|
77
|
+
// reflection — never construct or invoke anything — so `unknown` params would reject every real,
|
|
78
|
+
// differently-typed class assigned to them.
|
|
79
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- see comment above */
|
|
80
|
+
/** A NestJS controller/guard class reference — reflected on via metadata, never constructed here. */
|
|
81
|
+
type Ctor = abstract new (...args: any[]) => object;
|
|
82
|
+
|
|
83
|
+
/** A route-handler method — reflected on and invoked by Nest's own pipeline, never called directly here. */
|
|
84
|
+
type HandlerFn = (...args: any[]) => unknown;
|
|
85
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
86
|
+
|
|
66
87
|
/** A guard entry in `@UseGuards` may be a class or an already-constructed instance; both reduce to a class. */
|
|
67
|
-
type GuardEntry =
|
|
88
|
+
type GuardEntry = Ctor | { constructor: Ctor };
|
|
68
89
|
|
|
69
|
-
const guardClassesOn = (target: object):
|
|
70
|
-
(
|
|
90
|
+
const guardClassesOn = (target: object): Ctor[] =>
|
|
91
|
+
(
|
|
92
|
+
(Reflect.getMetadata(GUARDS_METADATA, target) as
|
|
93
|
+
GuardEntry[] | undefined) ?? []
|
|
94
|
+
).map((g) => (typeof g === "function" ? g : g.constructor));
|
|
71
95
|
|
|
72
96
|
const joinPath = (...parts: unknown[]) =>
|
|
73
97
|
"/" +
|
|
@@ -78,13 +102,21 @@ const joinPath = (...parts: unknown[]) =>
|
|
|
78
102
|
.join("/");
|
|
79
103
|
|
|
80
104
|
/** Own methods carrying Nest's route metadata. Controllers here are flat classes; inherited routes are not a shape this combo uses. */
|
|
81
|
-
function routeHandlersOf(
|
|
105
|
+
function routeHandlersOf(
|
|
106
|
+
controller: Ctor,
|
|
107
|
+
): Array<{ name: string; handler: HandlerFn }> {
|
|
82
108
|
const prototype = controller.prototype as Record<string, unknown>;
|
|
83
109
|
return Object.getOwnPropertyNames(prototype)
|
|
84
110
|
.filter((name) => name !== "constructor")
|
|
85
111
|
.map((name) => ({ name, handler: prototype[name] }))
|
|
86
|
-
.filter(
|
|
87
|
-
|
|
112
|
+
.filter(
|
|
113
|
+
(entry): entry is { name: string; handler: HandlerFn } =>
|
|
114
|
+
typeof entry.handler === "function",
|
|
115
|
+
)
|
|
116
|
+
.filter(
|
|
117
|
+
(entry) =>
|
|
118
|
+
Reflect.getMetadata(PATH_METADATA, entry.handler) !== undefined,
|
|
119
|
+
);
|
|
88
120
|
}
|
|
89
121
|
|
|
90
122
|
/**
|
|
@@ -103,7 +135,10 @@ function routeHandlersOf(controller: Function): Array<{ name: string; handler: F
|
|
|
103
135
|
* @param controllers the module's controller classes
|
|
104
136
|
* @param guards the two guard classes whose presence defines tiers 2 and 3
|
|
105
137
|
*/
|
|
106
|
-
export function assertEveryRouteDeclaresATier(
|
|
138
|
+
export function assertEveryRouteDeclaresATier(
|
|
139
|
+
controllers: readonly Ctor[],
|
|
140
|
+
guards: { authentication: Ctor; ability: Ctor },
|
|
141
|
+
): void {
|
|
107
142
|
const offenders: string[] = [];
|
|
108
143
|
|
|
109
144
|
for (const controller of controllers) {
|
|
@@ -111,12 +146,19 @@ export function assertEveryRouteDeclaresATier(controllers: readonly Function[],
|
|
|
111
146
|
const controllerGuards = guardClassesOn(controller);
|
|
112
147
|
|
|
113
148
|
for (const { name, handler } of routeHandlersOf(controller)) {
|
|
114
|
-
const method =
|
|
149
|
+
const method =
|
|
150
|
+
RequestMethod[
|
|
151
|
+
Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod
|
|
152
|
+
] ?? "?";
|
|
115
153
|
const route = `${method} ${joinPath(controllerPath, Reflect.getMetadata(PATH_METADATA, handler))} (${controller.name}.${name})`;
|
|
116
154
|
|
|
117
|
-
const tier: RouteTier | undefined =
|
|
155
|
+
const tier: RouteTier | undefined =
|
|
156
|
+
Reflect.getMetadata(ROUTE_TIER_KEY, handler) ??
|
|
157
|
+
Reflect.getMetadata(ROUTE_TIER_KEY, controller);
|
|
118
158
|
if (!tier) {
|
|
119
|
-
offenders.push(
|
|
159
|
+
offenders.push(
|
|
160
|
+
`${route} — declares no tier: add @Public(), @Authenticated(), or @CheckAbility(slug)`,
|
|
161
|
+
);
|
|
120
162
|
continue;
|
|
121
163
|
}
|
|
122
164
|
|
|
@@ -126,11 +168,22 @@ export function assertEveryRouteDeclaresATier(controllers: readonly Function[],
|
|
|
126
168
|
const authenticated = attached.includes(guards.authentication);
|
|
127
169
|
const ability = attached.includes(guards.ability);
|
|
128
170
|
|
|
129
|
-
if (tier === "public" && (authenticated || ability))
|
|
130
|
-
|
|
131
|
-
|
|
171
|
+
if (tier === "public" && (authenticated || ability))
|
|
172
|
+
offenders.push(
|
|
173
|
+
`${route} — declares @Public() but is behind ${attached.map((g) => g.name).join(", ")}`,
|
|
174
|
+
);
|
|
175
|
+
if (tier === "authenticated" && !authenticated)
|
|
176
|
+
offenders.push(
|
|
177
|
+
`${route} — declares @Authenticated() but has no ${guards.authentication.name}`,
|
|
178
|
+
);
|
|
179
|
+
if (tier === "authenticated" && ability)
|
|
180
|
+
offenders.push(
|
|
181
|
+
`${route} — declares @Authenticated() but is behind ${guards.ability.name}; use @CheckAbility instead`,
|
|
182
|
+
);
|
|
132
183
|
if (tier === "ability" && !(authenticated && ability))
|
|
133
|
-
offenders.push(
|
|
184
|
+
offenders.push(
|
|
185
|
+
`${route} — declares @CheckAbility but is not behind ${guards.authentication.name} + ${guards.ability.name}`,
|
|
186
|
+
);
|
|
134
187
|
}
|
|
135
188
|
}
|
|
136
189
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import tseslint from "typescript-eslint";
|
|
3
|
+
|
|
4
|
+
export default tseslint.config(
|
|
5
|
+
{ ignores: [".variant/**", "node_modules/**", "dist/**", "generated/**"] },
|
|
6
|
+
js.configs.recommended,
|
|
7
|
+
...tseslint.configs.recommended,
|
|
8
|
+
{
|
|
9
|
+
rules: {
|
|
10
|
+
// This combo's leading-underscore convention (e.g. an interface-required parameter that's
|
|
11
|
+
// deliberately unused) predates this eslint config — respect it rather than flag it.
|
|
12
|
+
"@typescript-eslint/no-unused-vars": [
|
|
13
|
+
"error",
|
|
14
|
+
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"materialize": "node scripts/materialize.mjs",
|
|
9
9
|
"prisma:generate": "node scripts/materialize.mjs --all --generate",
|
|
10
10
|
"typecheck": "npm run prisma:generate && tsc -p .variant/base && tsc -p .variant/workspaces",
|
|
11
|
+
"lint": "eslint shared variants",
|
|
11
12
|
"prove-cycle": "node scripts/prove-cycle.mjs",
|
|
12
13
|
"migrate": "node scripts/migrate.mjs",
|
|
13
14
|
"seed": "node scripts/seed.mjs"
|
|
@@ -30,12 +31,15 @@
|
|
|
30
31
|
"socket.io": "^4.8.3"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
34
|
+
"@eslint/js": "^9.39.5",
|
|
33
35
|
"@types/express": "^5.0.6",
|
|
34
36
|
"@types/node": "^26.2.0",
|
|
35
37
|
"dotenv": "^17.4.2",
|
|
38
|
+
"eslint": "^9.39.5",
|
|
36
39
|
"prisma": "^7.9.1",
|
|
37
40
|
"tsx": "^4.23.11",
|
|
38
|
-
"typescript": "^
|
|
41
|
+
"typescript": "^6.0.3",
|
|
42
|
+
"typescript-eslint": "^8.70.0",
|
|
39
43
|
"vitest": "^4.1.10"
|
|
40
44
|
}
|
|
41
45
|
}
|