-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.ts
More file actions
137 lines (119 loc) · 4.63 KB
/
proxy.ts
File metadata and controls
137 lines (119 loc) · 4.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import { NextRequest, NextResponse } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";
import { decodePayload, IMPERSONATION_COOKIE } from "@/lib/portal/impersonation";
const ADMIN_ROUTES = [
"/dashboard",
"/clients",
"/tasks",
"/time",
"/invoices",
"/reports",
"/settings",
];
function isAdminRoute(pathname: string): boolean {
return ADMIN_ROUTES.some(
(route) => pathname === route || pathname.startsWith(route + "/")
);
}
function isPortalProtectedRoute(pathname: string): boolean {
// Portal routes except the login page and the auth-callback page
// (auth-callback must be reachable before the session is established)
return (
pathname.startsWith("/portal/") &&
!pathname.endsWith("/login") &&
!pathname.includes("/login?") &&
!pathname.endsWith("/auth-callback")
);
}
/**
* Extracts the tenant slug from the Host header.
* Returns null for localhost, www, or the bare base domain.
* Example: "acme.taskflow.com" → "acme"
*/
export function getTenantSlugFromHost(host: string): string | null {
const baseDomain = process.env.NEXT_PUBLIC_BASE_DOMAIN ?? "localhost";
// Strip port (e.g. "acme.taskflow.com:3000" → "acme.taskflow.com")
const hostWithoutPort = host.split(":")[0];
if (!hostWithoutPort.includes(baseDomain)) return null;
// Bare base domain — no subdomain present
if (hostWithoutPort === baseDomain) return null;
const subdomain = hostWithoutPort.split(".")[0];
if (!subdomain || subdomain === "www") return null;
return subdomain;
}
/** Returns true if the request carries a valid (non-expired) impersonation cookie for the given slug. */
function hasValidImpersonationCookie(request: NextRequest, urlSlug: string): boolean {
const raw = request.cookies.get(IMPERSONATION_COOKIE)?.value;
if (!raw) return false;
const payload = decodePayload(raw);
return !!payload && payload.tenantSlug === urlSlug;
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Extract tenant slug from subdomain
const host = request.headers.get("host") ?? "";
const tenantSlug = getTenantSlugFromHost(host);
// Inject x-tenant-slug header into every request so downstream Server
// Components and Route Handlers can read it without re-parsing the host.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-tenant-slug", tenantSlug ?? "");
// Always refresh the session (pass mutated headers along)
const { supabaseResponse, user } = await updateSession(
new NextRequest(request.url, { headers: requestHeaders, method: request.method, body: request.body })
);
// Helper: clone supabaseResponse but carry our custom request headers forward
function withTenantHeader(res: NextResponse): NextResponse {
res.headers.set("x-tenant-slug", tenantSlug ?? "");
return res;
}
// Admin route guard
if (isAdminRoute(pathname)) {
if (!user) {
return NextResponse.redirect(new URL("/auth/login", request.url));
}
if (user.app_metadata?.role !== "admin") {
// Authenticated client users should land on their portal, not the login page
if (user.app_metadata?.tenant_slug) {
return NextResponse.redirect(new URL("/portal", request.url));
}
return NextResponse.redirect(new URL("/auth/login", request.url));
}
return withTenantHeader(supabaseResponse);
}
// Portal route guard
if (isPortalProtectedRoute(pathname)) {
// Prefer subdomain-based slug; fall back to URL path for local dev
const urlSlug = tenantSlug ?? pathname.split("/")[2] ?? "";
if (!user) {
return NextResponse.redirect(
new URL("/portal/login", request.url)
);
}
// Admin impersonation: allow admins through if they have a valid cookie for this tenant
if (user.app_metadata?.role === "admin") {
if (hasValidImpersonationCookie(request, urlSlug)) {
return withTenantHeader(supabaseResponse);
}
return NextResponse.redirect(
new URL("/portal/login", request.url)
);
}
if (user.app_metadata?.role !== "client") {
return NextResponse.redirect(
new URL("/portal/login", request.url)
);
}
// Redirect client to their own tenant if they navigate to another
const userSlug = user.app_metadata?.tenant_slug as string | undefined;
if (userSlug && userSlug !== urlSlug) {
return NextResponse.redirect(new URL("/portal", request.url));
}
return withTenantHeader(supabaseResponse);
}
return withTenantHeader(supabaseResponse);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};