@seedgrid/fe-subdomain-tenancy 2026.7.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 +75 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +3 -0
- package/dist/client/tenant-provider.d.ts +11 -0
- package/dist/client/tenant-provider.d.ts.map +1 -0
- package/dist/client/tenant-provider.js +19 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +16 -0
- package/dist/module.json +14 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +10 -0
- package/dist/server/create-subdomain-tenancy-server.d.ts +12 -0
- package/dist/server/create-subdomain-tenancy-server.d.ts.map +1 -0
- package/dist/server/create-subdomain-tenancy-server.js +18 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/dist/subdomain-rules.d.ts +13 -0
- package/dist/subdomain-rules.d.ts.map +1 -0
- package/dist/subdomain-rules.js +82 -0
- package/dist/tenant-signup.d.ts +59 -0
- package/dist/tenant-signup.d.ts.map +1 -0
- package/dist/tenant-signup.js +5 -0
- package/package.json +55 -0
- package/scaffold/README.md +70 -0
- package/scaffold/src/app/(public)/signup/page.tsx +1165 -0
- package/scaffold/src/app/api/locations/cities/route.ts +125 -0
- package/scaffold/src/app/api/locations/states/route.ts +120 -0
- package/scaffold/src/modules/subdomain-tenancy/index.ts +20 -0
- package/scaffold/src/modules/subdomain-tenancy/server/create-public-tenant-action.ts +113 -0
- package/scaffold/src/modules/subdomain-tenancy/server/public-api-base-url.ts +28 -0
- package/scaffold/src/modules/subdomain-tenancy/server/resend-public-tenant-activation-email-action.ts +118 -0
- package/scaffold/src/modules/subdomain-tenancy/server/validate-public-tenant-email-action.ts +138 -0
- package/scaffold/src/modules/subdomain-tenancy/signup/types.ts +129 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const CITIES_API_BASE_URL = "https://cities.seedgrid.com.br";
|
|
4
|
+
|
|
5
|
+
type CityOption = {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
data: {
|
|
10
|
+
cityId: string;
|
|
11
|
+
stateId: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function GET(request: NextRequest) {
|
|
16
|
+
const query = request.nextUrl.searchParams.get("query")?.trim().toLowerCase() ?? "";
|
|
17
|
+
const stateId = request.nextUrl.searchParams.get("stateId")?.trim() ?? "";
|
|
18
|
+
const apiUsername = process.env.SEEDGRID_CITIES_API_USERNAME?.trim();
|
|
19
|
+
const apiKey = process.env.SEEDGRID_CITIES_API_KEY?.trim();
|
|
20
|
+
const authorizationHeader = apiUsername && apiKey
|
|
21
|
+
? `Basic ${Buffer.from(`${apiUsername}:${apiKey}`).toString("base64")}`
|
|
22
|
+
: "";
|
|
23
|
+
|
|
24
|
+
if (!authorizationHeader || !stateId) {
|
|
25
|
+
return NextResponse.json([], { status: 200 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const upstream = await fetch(
|
|
30
|
+
`${CITIES_API_BASE_URL}/cities?stateId=${encodeURIComponent(stateId)}`,
|
|
31
|
+
{
|
|
32
|
+
method: "GET",
|
|
33
|
+
headers: {
|
|
34
|
+
accept: "application/json",
|
|
35
|
+
Authorization: authorizationHeader,
|
|
36
|
+
},
|
|
37
|
+
cache: "no-store",
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!upstream.ok) {
|
|
42
|
+
return NextResponse.json([], { status: upstream.status });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const payload = await upstream.json();
|
|
46
|
+
const options = normalizeCityOptions(payload).filter((item) => {
|
|
47
|
+
if (!query) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return item.label.toLowerCase().includes(query);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return NextResponse.json(options, { status: 200 });
|
|
55
|
+
} catch {
|
|
56
|
+
return NextResponse.json([], { status: 200 });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeCityOptions(payload: unknown): CityOption[] {
|
|
61
|
+
const records = Array.isArray(payload)
|
|
62
|
+
? payload
|
|
63
|
+
: Array.isArray(readProperty(payload, "data"))
|
|
64
|
+
? (readProperty(payload, "data") as unknown[])
|
|
65
|
+
: [];
|
|
66
|
+
|
|
67
|
+
return records
|
|
68
|
+
.map((record) => {
|
|
69
|
+
const cityId = readNumber(record, "id");
|
|
70
|
+
const stateId =
|
|
71
|
+
readNumber(record, "stateId") ??
|
|
72
|
+
readNumber(record, "estadoId") ??
|
|
73
|
+
readNumber(record, "state_id");
|
|
74
|
+
const cityName =
|
|
75
|
+
readString(record, "name") ??
|
|
76
|
+
readString(record, "nome") ??
|
|
77
|
+
readString(record, "label") ??
|
|
78
|
+
readString(record, "descricao");
|
|
79
|
+
|
|
80
|
+
if (!cityId || !stateId || !cityName) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
id: cityId,
|
|
86
|
+
label: cityName,
|
|
87
|
+
value: cityName,
|
|
88
|
+
data: {
|
|
89
|
+
cityId,
|
|
90
|
+
stateId,
|
|
91
|
+
},
|
|
92
|
+
} satisfies CityOption;
|
|
93
|
+
})
|
|
94
|
+
.filter((item): item is CityOption => item !== null);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readProperty(value: unknown, key: string) {
|
|
98
|
+
if (!value || typeof value !== "object") {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Reflect.get(value, key);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readString(value: unknown, key: string) {
|
|
106
|
+
const candidate = readProperty(value, key);
|
|
107
|
+
|
|
108
|
+
return typeof candidate === "string" && candidate.trim()
|
|
109
|
+
? candidate.trim()
|
|
110
|
+
: null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readNumber(value: unknown, key: string) {
|
|
114
|
+
const candidate = readProperty(value, key);
|
|
115
|
+
|
|
116
|
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
117
|
+
return String(candidate);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
121
|
+
return candidate.trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const CITIES_API_BASE_URL = "https://cities.seedgrid.com.br";
|
|
4
|
+
|
|
5
|
+
type StateOption = {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
data: {
|
|
10
|
+
stateId: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function GET(request: NextRequest) {
|
|
15
|
+
const query = request.nextUrl.searchParams.get("query")?.trim().toLowerCase() ?? "";
|
|
16
|
+
const apiUsername = process.env.SEEDGRID_CITIES_API_USERNAME?.trim();
|
|
17
|
+
const apiKey = process.env.SEEDGRID_CITIES_API_KEY?.trim();
|
|
18
|
+
const authorizationHeader = apiUsername && apiKey
|
|
19
|
+
? `Basic ${Buffer.from(`${apiUsername}:${apiKey}`).toString("base64")}`
|
|
20
|
+
: "";
|
|
21
|
+
|
|
22
|
+
if (!authorizationHeader) {
|
|
23
|
+
return NextResponse.json([], { status: 200 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const upstream = await fetch(`${CITIES_API_BASE_URL}/states?countryId=1`, {
|
|
28
|
+
method: "GET",
|
|
29
|
+
headers: {
|
|
30
|
+
accept: "application/json",
|
|
31
|
+
Authorization: authorizationHeader,
|
|
32
|
+
},
|
|
33
|
+
cache: "no-store",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!upstream.ok) {
|
|
37
|
+
return NextResponse.json([], { status: upstream.status });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const payload = await upstream.json();
|
|
41
|
+
const options = normalizeStateOptions(payload).filter((item) => {
|
|
42
|
+
if (!query) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return `${item.value} ${item.label}`.toLowerCase().includes(query);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return NextResponse.json(options, { status: 200 });
|
|
50
|
+
} catch {
|
|
51
|
+
return NextResponse.json([], { status: 200 });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeStateOptions(payload: unknown): StateOption[] {
|
|
56
|
+
const records = Array.isArray(payload)
|
|
57
|
+
? payload
|
|
58
|
+
: Array.isArray(readProperty(payload, "data"))
|
|
59
|
+
? (readProperty(payload, "data") as unknown[])
|
|
60
|
+
: [];
|
|
61
|
+
|
|
62
|
+
return records
|
|
63
|
+
.map((record) => {
|
|
64
|
+
const stateId = readNumber(record, "id");
|
|
65
|
+
const stateCode =
|
|
66
|
+
readString(record, "uf") ??
|
|
67
|
+
readString(record, "code") ??
|
|
68
|
+
readString(record, "abbreviation") ??
|
|
69
|
+
readString(record, "sigla");
|
|
70
|
+
const stateName =
|
|
71
|
+
readString(record, "name") ??
|
|
72
|
+
readString(record, "nome") ??
|
|
73
|
+
readString(record, "label") ??
|
|
74
|
+
readString(record, "descricao");
|
|
75
|
+
|
|
76
|
+
if (!stateId || !stateCode || !stateName) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
id: stateId,
|
|
82
|
+
label: `${stateCode} - ${stateName}`,
|
|
83
|
+
value: stateCode,
|
|
84
|
+
data: {
|
|
85
|
+
stateId,
|
|
86
|
+
},
|
|
87
|
+
} satisfies StateOption;
|
|
88
|
+
})
|
|
89
|
+
.filter((item): item is StateOption => item !== null);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readProperty(value: unknown, key: string) {
|
|
93
|
+
if (!value || typeof value !== "object") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return Reflect.get(value, key);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readString(value: unknown, key: string) {
|
|
101
|
+
const candidate = readProperty(value, key);
|
|
102
|
+
|
|
103
|
+
return typeof candidate === "string" && candidate.trim()
|
|
104
|
+
? candidate.trim()
|
|
105
|
+
: null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readNumber(value: unknown, key: string) {
|
|
109
|
+
const candidate = readProperty(value, key);
|
|
110
|
+
|
|
111
|
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
112
|
+
return String(candidate);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
116
|
+
return candidate.trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
buildCreatePublicTenantPayload,
|
|
5
|
+
createEmptyPublicTenantSignupForm,
|
|
6
|
+
type CreatePublicTenantActionResult,
|
|
7
|
+
type CreatePublicTenantPayload,
|
|
8
|
+
type PublicTenantSignupFormValues,
|
|
9
|
+
} from "./signup/types";
|
|
10
|
+
|
|
11
|
+
// Promovidos pra lib versionada (@seedgrid/fe-subdomain-tenancy) — ver ADR
|
|
12
|
+
// 0004/0005 e o scaffold README do fe-security pro criterio lib vs scaffold
|
|
13
|
+
// usado nesta extracao (mesma regra: import `@/...` real = scaffold).
|
|
14
|
+
export {
|
|
15
|
+
RESERVED_SUBDOMAINS,
|
|
16
|
+
isReservedSubdomain,
|
|
17
|
+
normalizeSubdomain,
|
|
18
|
+
validateSubdomain,
|
|
19
|
+
} from "@seedgrid/fe-subdomain-tenancy";
|
|
20
|
+
export { TenantProvider, useTenantContext } from "@seedgrid/fe-subdomain-tenancy/client";
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
// seedgrid:managed
|
|
4
|
+
|
|
5
|
+
import { getTranslator } from "@/i18n";
|
|
6
|
+
import { ApiClientError, createApiClient } from "@seedgrid/fe-core";
|
|
7
|
+
import { captureException, runGuarded } from "@/modules/core";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type CreatePublicTenantActionResult,
|
|
11
|
+
type CreatePublicTenantPayload,
|
|
12
|
+
} from "../signup/types";
|
|
13
|
+
import { resolvePublicApiBaseUrl } from "./public-api-base-url";
|
|
14
|
+
import { normalizeSubdomain, validateSubdomain } from "@seedgrid/fe-subdomain-tenancy";
|
|
15
|
+
|
|
16
|
+
export async function createPublicTenantAction(
|
|
17
|
+
payload: CreatePublicTenantPayload
|
|
18
|
+
): Promise<CreatePublicTenantActionResult> {
|
|
19
|
+
return runGuarded(async () => {
|
|
20
|
+
const t = getTranslator();
|
|
21
|
+
const normalizedSubdomain = normalizeSubdomain(payload.subdomain);
|
|
22
|
+
const subdomainValidation = validateSubdomain(normalizedSubdomain, t);
|
|
23
|
+
|
|
24
|
+
if (subdomainValidation) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
message: subdomainValidation,
|
|
28
|
+
status: 400,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const client = createApiClient({
|
|
33
|
+
baseUrl: await resolvePublicApiBaseUrl(),
|
|
34
|
+
defaultCache: "no-store",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await client.post("/public/tenants", {
|
|
39
|
+
body: {
|
|
40
|
+
...payload,
|
|
41
|
+
subdomain: normalizedSubdomain,
|
|
42
|
+
},
|
|
43
|
+
requireAuth: false,
|
|
44
|
+
retry: {
|
|
45
|
+
attempts: 2,
|
|
46
|
+
baseDelayMs: 350,
|
|
47
|
+
retryOn: [429, 502, 503, 504],
|
|
48
|
+
},
|
|
49
|
+
cache: "no-store",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
tenantSubdomain: normalizedSubdomain,
|
|
55
|
+
rootEmail: payload.rootEmail,
|
|
56
|
+
message: t("multitenancy.signup.actions.create.success"),
|
|
57
|
+
};
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof ApiClientError) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
message: extractApiErrorMessage(error, t),
|
|
63
|
+
status: error.status,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await captureException(error, {
|
|
68
|
+
area: "server",
|
|
69
|
+
source: "multitenancy.createPublicTenantAction",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
message: t("multitenancy.signup.actions.create.unavailable"),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractApiErrorMessage(error: ApiClientError, t: ReturnType<typeof getTranslator>) {
|
|
81
|
+
const body = error.responseBody;
|
|
82
|
+
|
|
83
|
+
if (typeof body === "string" && body.trim()) {
|
|
84
|
+
return body;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (body && typeof body === "object") {
|
|
88
|
+
const message =
|
|
89
|
+
pickObjectString(body, "userMessage") ??
|
|
90
|
+
pickObjectString(body, "message") ??
|
|
91
|
+
pickObjectString(body, "error") ??
|
|
92
|
+
pickObjectString(body, "detail");
|
|
93
|
+
|
|
94
|
+
if (message) {
|
|
95
|
+
return message;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (error.status === 409) {
|
|
100
|
+
return t("multitenancy.signup.actions.create.conflict");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (error.status === 400) {
|
|
104
|
+
return t("multitenancy.signup.actions.create.invalid_payload");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return t("multitenancy.signup.actions.create.generic");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pickObjectString(value: object, key: string) {
|
|
111
|
+
const candidate = Reflect.get(value, key);
|
|
112
|
+
return typeof candidate === "string" && candidate.trim() ? candidate : null;
|
|
113
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
// seedgrid:managed
|
|
4
|
+
|
|
5
|
+
import { headers } from "next/headers";
|
|
6
|
+
|
|
7
|
+
import { resolveApiBaseUrlForHostname } from "@/config/environment";
|
|
8
|
+
|
|
9
|
+
export async function resolvePublicApiBaseUrl() {
|
|
10
|
+
const requestHeaders = await headers();
|
|
11
|
+
const hostname = resolveRequestHostname(requestHeaders);
|
|
12
|
+
|
|
13
|
+
if (!hostname) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"Could not resolve the public API base URL. Access the app through a configured domain hostname and provide the corresponding host header."
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return resolveApiBaseUrlForHostname(hostname);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveRequestHostname(requestHeaders: Headers) {
|
|
23
|
+
const forwardedHost = requestHeaders.get("x-forwarded-host")?.trim();
|
|
24
|
+
const hostHeader = requestHeaders.get("host")?.trim();
|
|
25
|
+
const candidate = forwardedHost || hostHeader || "";
|
|
26
|
+
|
|
27
|
+
return candidate.replace(/:\d+$/, "");
|
|
28
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
// seedgrid:managed
|
|
4
|
+
|
|
5
|
+
import { getTranslator } from "@/i18n";
|
|
6
|
+
import { ApiClientError, createApiClient } from "@seedgrid/fe-core";
|
|
7
|
+
import { captureException, runGuarded } from "@/modules/core";
|
|
8
|
+
|
|
9
|
+
import { normalizeSubdomain, validateSubdomain } from "@seedgrid/fe-subdomain-tenancy";
|
|
10
|
+
import { resolvePublicApiBaseUrl } from "./public-api-base-url";
|
|
11
|
+
|
|
12
|
+
type ResendPublicTenantActivationEmailActionResult =
|
|
13
|
+
| {
|
|
14
|
+
ok: true;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
ok: false;
|
|
19
|
+
message: string;
|
|
20
|
+
status?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function resendPublicTenantActivationEmailAction(
|
|
24
|
+
subdomain: string
|
|
25
|
+
): Promise<ResendPublicTenantActivationEmailActionResult> {
|
|
26
|
+
return runGuarded(async () => {
|
|
27
|
+
const t = getTranslator();
|
|
28
|
+
const normalizedSubdomain = normalizeSubdomain(subdomain);
|
|
29
|
+
const subdomainValidation = validateSubdomain(normalizedSubdomain, t);
|
|
30
|
+
|
|
31
|
+
if (subdomainValidation) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
message: subdomainValidation,
|
|
35
|
+
status: 400,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const client = createApiClient({
|
|
40
|
+
baseUrl: await resolvePublicApiBaseUrl(),
|
|
41
|
+
defaultCache: "no-store",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const response = await client.post<{ message?: string }>(
|
|
46
|
+
"/public/tenants/resend-email",
|
|
47
|
+
{
|
|
48
|
+
query: {
|
|
49
|
+
subdomain: normalizedSubdomain,
|
|
50
|
+
},
|
|
51
|
+
requireAuth: false,
|
|
52
|
+
retry: {
|
|
53
|
+
attempts: 2,
|
|
54
|
+
baseDelayMs: 350,
|
|
55
|
+
retryOn: [429, 502, 503, 504],
|
|
56
|
+
},
|
|
57
|
+
cache: "no-store",
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
message:
|
|
64
|
+
response.message?.trim() ||
|
|
65
|
+
t("multitenancy.signup.actions.resend.success"),
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error instanceof ApiClientError) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
message: extractApiErrorMessage(error, t),
|
|
72
|
+
status: error.status,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await captureException(error, {
|
|
77
|
+
area: "server",
|
|
78
|
+
source: "multitenancy.resendPublicTenantActivationEmailAction",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
message: t("multitenancy.signup.actions.resend.unavailable"),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractApiErrorMessage(error: ApiClientError, t: ReturnType<typeof getTranslator>) {
|
|
90
|
+
const body = error.responseBody;
|
|
91
|
+
|
|
92
|
+
if (typeof body === "string" && body.trim()) {
|
|
93
|
+
return body;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (body && typeof body === "object") {
|
|
97
|
+
const message =
|
|
98
|
+
pickObjectString(body, "userMessage") ??
|
|
99
|
+
pickObjectString(body, "message") ??
|
|
100
|
+
pickObjectString(body, "error") ??
|
|
101
|
+
pickObjectString(body, "detail");
|
|
102
|
+
|
|
103
|
+
if (message) {
|
|
104
|
+
return message;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (error.status === 404) {
|
|
109
|
+
return t("multitenancy.signup.actions.resend.not_found");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return t("multitenancy.signup.actions.resend.generic");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pickObjectString(value: object, key: string) {
|
|
116
|
+
const candidate = Reflect.get(value, key);
|
|
117
|
+
return typeof candidate === "string" && candidate.trim() ? candidate : null;
|
|
118
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
// seedgrid:managed
|
|
4
|
+
|
|
5
|
+
import { getTranslator } from "@/i18n";
|
|
6
|
+
import { ApiClientError, createApiClient } from "@seedgrid/fe-core";
|
|
7
|
+
import { captureException, runGuarded } from "@/modules/core";
|
|
8
|
+
|
|
9
|
+
import { normalizeSubdomain, validateSubdomain } from "@seedgrid/fe-subdomain-tenancy";
|
|
10
|
+
import { resolvePublicApiBaseUrl } from "./public-api-base-url";
|
|
11
|
+
|
|
12
|
+
type ValidatePublicTenantEmailActionPayload = {
|
|
13
|
+
subdomain: string;
|
|
14
|
+
code: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ValidatePublicTenantEmailActionResult =
|
|
18
|
+
| {
|
|
19
|
+
ok: true;
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
ok: false;
|
|
24
|
+
message: string;
|
|
25
|
+
status?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function validatePublicTenantEmailAction(
|
|
29
|
+
payload: ValidatePublicTenantEmailActionPayload
|
|
30
|
+
): Promise<ValidatePublicTenantEmailActionResult> {
|
|
31
|
+
return runGuarded(async () => {
|
|
32
|
+
const t = getTranslator();
|
|
33
|
+
const normalizedSubdomain = normalizeSubdomain(payload.subdomain);
|
|
34
|
+
const subdomainValidation = validateSubdomain(normalizedSubdomain, t);
|
|
35
|
+
|
|
36
|
+
if (subdomainValidation) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
message: subdomainValidation,
|
|
40
|
+
status: 400,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const normalizedCode = payload.code.replace(/\D/g, "").slice(0, 6);
|
|
45
|
+
|
|
46
|
+
if (normalizedCode.length !== 6) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
message: t("multitenancy.signup.actions.validate.code_required"),
|
|
50
|
+
status: 400,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const client = createApiClient({
|
|
55
|
+
baseUrl: await resolvePublicApiBaseUrl(),
|
|
56
|
+
defaultCache: "no-store",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const response = await client.post<{ message?: string }>(
|
|
61
|
+
"/public/tenants/validate-email",
|
|
62
|
+
{
|
|
63
|
+
body: {
|
|
64
|
+
subdomain: normalizedSubdomain,
|
|
65
|
+
code: normalizedCode,
|
|
66
|
+
},
|
|
67
|
+
requireAuth: false,
|
|
68
|
+
retry: {
|
|
69
|
+
attempts: 2,
|
|
70
|
+
baseDelayMs: 350,
|
|
71
|
+
retryOn: [429, 502, 503, 504],
|
|
72
|
+
},
|
|
73
|
+
cache: "no-store",
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
message:
|
|
80
|
+
response.message?.trim() ||
|
|
81
|
+
t("multitenancy.signup.actions.validate.success"),
|
|
82
|
+
};
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof ApiClientError) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
message: extractApiErrorMessage(error, t),
|
|
88
|
+
status: error.status,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await captureException(error, {
|
|
93
|
+
area: "server",
|
|
94
|
+
source: "multitenancy.validatePublicTenantEmailAction",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
message: t("multitenancy.signup.actions.validate.unavailable"),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractApiErrorMessage(error: ApiClientError, t: ReturnType<typeof getTranslator>) {
|
|
106
|
+
const body = error.responseBody;
|
|
107
|
+
|
|
108
|
+
if (typeof body === "string" && body.trim()) {
|
|
109
|
+
return body;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (body && typeof body === "object") {
|
|
113
|
+
const message =
|
|
114
|
+
pickObjectString(body, "userMessage") ??
|
|
115
|
+
pickObjectString(body, "message") ??
|
|
116
|
+
pickObjectString(body, "error") ??
|
|
117
|
+
pickObjectString(body, "detail");
|
|
118
|
+
|
|
119
|
+
if (message) {
|
|
120
|
+
return message;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (error.status === 404) {
|
|
125
|
+
return t("multitenancy.signup.actions.validate.not_found");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (error.status === 400) {
|
|
129
|
+
return t("multitenancy.signup.actions.validate.invalid_code");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return t("multitenancy.signup.actions.validate.generic");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pickObjectString(value: object, key: string) {
|
|
136
|
+
const candidate = Reflect.get(value, key);
|
|
137
|
+
return typeof candidate === "string" && candidate.trim() ? candidate : null;
|
|
138
|
+
}
|