@lightupai/polaris 0.0.1

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/src/web/app.ts ADDED
@@ -0,0 +1,397 @@
1
+ import { Hono } from "hono";
2
+ import { Google } from "arctic";
3
+ import { createToken, verifyToken } from "../service/auth";
4
+ import {
5
+ createOrg,
6
+ getOrg,
7
+ getOrgByDomain,
8
+ createUser,
9
+ getUserByEmail,
10
+ upsertUser,
11
+ setOrgSlack,
12
+ type Sql,
13
+ } from "../service/db";
14
+ import { layout, nav } from "./layout";
15
+ import { renderSetupView, renderActiveView, renderProfileView, renderErrorView } from "./views";
16
+ import { renderLandingPage } from "./pages";
17
+ import { createSystemChannel, postSystemEvent } from "../slack/system";
18
+ import {
19
+ mockUser,
20
+ mockOrg,
21
+ mockOrgNoSlack,
22
+ mockProjects,
23
+ mockActiveSessions,
24
+ mockEmptySessions,
25
+ mockDevices,
26
+ } from "./fixtures";
27
+
28
+ // --- Google OAuth ---
29
+
30
+ function getGoogle(): Google {
31
+ return new Google(
32
+ process.env.GOOGLE_CLIENT_ID ?? "",
33
+ process.env.GOOGLE_CLIENT_SECRET ?? "",
34
+ process.env.GOOGLE_REDIRECT_URI ?? "http://localhost:3000/auth/google/callback"
35
+ );
36
+ }
37
+
38
+ const oauthStates = new Map<string, { type: "login" | "signup"; codeVerifier: string; timestamp: number }>();
39
+ const cliCallbackPorts = new Map<string, number>(); // state → CLI local server port
40
+
41
+ // If this auth flow was initiated by the CLI, redirect the token to the CLI's local server.
42
+ // Otherwise redirect to the dashboard.
43
+ function authRedirect(c: { redirect: (url: string) => Response }, state: string, token: string): Response {
44
+ const cliPort = cliCallbackPorts.get(state);
45
+ if (cliPort) {
46
+ cliCallbackPorts.delete(state);
47
+ return c.redirect(`http://127.0.0.1:${cliPort}/callback?token=${token}`);
48
+ }
49
+ return c.redirect(`/dashboard?token=${token}`);
50
+ }
51
+
52
+ setInterval(() => {
53
+ const now = Date.now();
54
+ for (const [key, val] of oauthStates) {
55
+ if (now - val.timestamp > 10 * 60 * 1000) oauthStates.delete(key);
56
+ }
57
+ }, 10 * 60 * 1000);
58
+
59
+ // --- App ---
60
+
61
+ export function createApp(sql: Sql) {
62
+ const app = new Hono();
63
+
64
+ // --- Landing page ---
65
+
66
+ app.get("/", (c) => {
67
+ return layout(renderLandingPage());
68
+ });
69
+
70
+ // --- Auth: single Google SSO flow for both signup and login ---
71
+
72
+ function startGoogleAuth(c: { redirect: (url: string) => Response }) {
73
+ const google = getGoogle();
74
+ const state = crypto.randomUUID();
75
+ const codeVerifier = crypto.randomUUID();
76
+ oauthStates.set(state, { type: "login", codeVerifier, timestamp: Date.now() });
77
+ const url = google.createAuthorizationURL(state, codeVerifier, ["openid", "email", "profile"]);
78
+ return c.redirect(url.toString());
79
+ }
80
+
81
+ app.get("/signup", async (c) => startGoogleAuth(c));
82
+ app.get("/login", async (c) => startGoogleAuth(c));
83
+
84
+ app.get("/auth/google/callback", async (c) => {
85
+ const code = c.req.query("code");
86
+ const state = c.req.query("state");
87
+ if (!code || !state) return layout(renderErrorView("Missing code or state.", "Try again", "/login"));
88
+
89
+ const stateData = oauthStates.get(state);
90
+ if (!stateData) return layout(renderErrorView("Invalid or expired OAuth state.", "Try again", "/login"));
91
+ oauthStates.delete(state);
92
+
93
+ const google = getGoogle();
94
+ let tokens;
95
+ try {
96
+ tokens = await google.validateAuthorizationCode(code, stateData.codeVerifier);
97
+ } catch {
98
+ return layout(renderErrorView("Authentication failed.", "Try again", "/login"));
99
+ }
100
+
101
+ const userInfoRes = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
102
+ headers: { Authorization: `Bearer ${tokens.accessToken()}` },
103
+ });
104
+ const userInfo = (await userInfoRes.json()) as { sub: string; email: string; name: string };
105
+ const { email, name } = userInfo;
106
+ const domain = email.split("@")[1];
107
+
108
+ // 1. Existing user → log in
109
+ const existingUser = await getUserByEmail(sql, email);
110
+ if (existingUser) {
111
+ const token = await createToken({
112
+ sub: existingUser.id,
113
+ email: existingUser.email,
114
+ name: existingUser.name,
115
+ org_id: existingUser.org_id,
116
+ participant_id: existingUser.participant_id,
117
+ });
118
+ return authRedirect(c, state, token);
119
+ }
120
+
121
+ // 2. Existing org for this domain → auto-join
122
+ const existingOrg = await getOrgByDomain(sql, domain);
123
+ if (existingOrg) {
124
+ const userId = crypto.randomUUID();
125
+ const participantId = `user:${name.toLowerCase().replace(/\s+/g, ".")}`;
126
+ await createUser(sql, userId, email, name, existingOrg.id, participantId);
127
+ const token = await createToken({ sub: userId, email, name, org_id: existingOrg.id, participant_id: participantId });
128
+ return authRedirect(c, state, token);
129
+ }
130
+
131
+ // 3. No org → auto-create from email domain
132
+ const orgName = domain.split(".")[0].charAt(0).toUpperCase() + domain.split(".")[0].slice(1);
133
+ const orgId = crypto.randomUUID();
134
+ try {
135
+ await createOrg(sql, orgId, orgName, domain);
136
+ } catch {
137
+ return layout(renderErrorView("Failed to create team. Please try again.", "Try again", "/login"));
138
+ }
139
+ const userId = crypto.randomUUID();
140
+ const participantId = `user:${name.toLowerCase().replace(/\s+/g, ".")}`;
141
+ await createUser(sql, userId, email, name, orgId, participantId);
142
+ const token = await createToken({ sub: userId, email, name, org_id: orgId, participant_id: participantId });
143
+ return authRedirect(c, state, token);
144
+ });
145
+
146
+ // --- Dashboard ---
147
+
148
+ app.get("/dashboard", async (c) => {
149
+ const token = c.req.query("token");
150
+ if (!token) return c.redirect("/login");
151
+
152
+ const payload = await verifyToken(token);
153
+ if (!payload) return c.redirect("/login");
154
+
155
+ const org = await getOrg(sql, payload.org_id);
156
+ if (!org) return c.redirect("/login");
157
+
158
+ // TODO: detect cliInstalled (check if user has ever hit /auth/token from CLI)
159
+ // TODO: detect hasConnectedSession (check if user has any sessions as driver in DB)
160
+ const ctx = {
161
+ token,
162
+ userName: payload.name,
163
+ orgName: org.name,
164
+ email: payload.email,
165
+ slackConnected: !!org.slack_team_id,
166
+ cliInstalled: false,
167
+ hasConnectedSession: false,
168
+ };
169
+
170
+ // TODO: query cloud service for active sessions for this user
171
+ const activeSessions: unknown[] = [];
172
+
173
+ if (activeSessions.length > 0) {
174
+ return layout(renderActiveView(ctx, [], []), "Polaris");
175
+ }
176
+ return layout(renderSetupView(ctx), "Polaris");
177
+ });
178
+
179
+ // --- Profile ---
180
+
181
+ app.get("/profile", async (c) => {
182
+ const token = c.req.query("token");
183
+ if (!token) return c.redirect("/login");
184
+
185
+ const payload = await verifyToken(token);
186
+ if (!payload) return c.redirect("/login");
187
+
188
+ const org = await getOrg(sql, payload.org_id);
189
+ if (!org) return c.redirect("/login");
190
+
191
+ const ctx = {
192
+ token,
193
+ userName: payload.name,
194
+ orgName: org.name,
195
+ email: payload.email,
196
+ slackConnected: !!org.slack_team_id,
197
+ cliInstalled: false,
198
+ hasConnectedSession: false,
199
+ };
200
+
201
+ return layout(renderProfileView(ctx, payload.participant_id), "Polaris - Profile");
202
+ });
203
+
204
+ // --- Preview (dev only — all view states on one page) ---
205
+
206
+ app.get("/preview", (c) => {
207
+ const mockToken = "preview-token";
208
+ const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, email: mockUser.email };
209
+
210
+ const fresh = { ...base, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
211
+ const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false };
212
+ const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false };
213
+ const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true };
214
+
215
+ return layout(`
216
+ <div class="max-w-5xl mx-auto px-6 py-12">
217
+ <h1 class="text-3xl font-bold text-gray-900 mb-2">View Preview</h1>
218
+ <p class="text-gray-500 mb-12">All dashboard states rendered on one page for visual testing.</p>
219
+
220
+ <div class="space-y-16">
221
+ <section>
222
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Setup: fresh (nothing done)</h2>
223
+ <p class="text-sm text-gray-400 mb-4">Brand new user, no steps completed.</p>
224
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
225
+ ${renderSetupView(fresh)}
226
+ </div>
227
+ </section>
228
+
229
+ <section>
230
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Setup: Slack connected</h2>
231
+ <p class="text-sm text-gray-400 mb-4">Floor is live, CLI not installed yet.</p>
232
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
233
+ ${renderSetupView(slackDone)}
234
+ </div>
235
+ </section>
236
+
237
+ <section>
238
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Setup: Slack + CLI done</h2>
239
+ <p class="text-sm text-gray-400 mb-4">Waiting for first session connection.</p>
240
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
241
+ ${renderSetupView(cliDone, mockDevices)}
242
+ </div>
243
+ </section>
244
+
245
+ <section>
246
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Active view (multiple sessions)</h2>
247
+ <p class="text-sm text-gray-400 mb-4">User is driver in one session, advisor in others.</p>
248
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
249
+ ${renderActiveView(allDone, mockActiveSessions, mockProjects, mockDevices)}
250
+ </div>
251
+ </section>
252
+
253
+ <section>
254
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Profile view</h2>
255
+ <p class="text-sm text-gray-400 mb-4">User identity, participant ID, API token.</p>
256
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
257
+ ${renderProfileView(allDone, mockUser.participant_id)}
258
+ </div>
259
+ </section>
260
+ <section>
261
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Error: auth failed</h2>
262
+ <p class="text-sm text-gray-400 mb-4">Google OAuth rejected or expired.</p>
263
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
264
+ ${renderErrorView("Authentication failed.", "Try again", "/login")}
265
+ </div>
266
+ </section>
267
+
268
+ <section>
269
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Error: expired OAuth state</h2>
270
+ <p class="text-sm text-gray-400 mb-4">Stale or replayed OAuth callback.</p>
271
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
272
+ ${renderErrorView("Invalid or expired OAuth state.", "Try again", "/login")}
273
+ </div>
274
+ </section>
275
+
276
+ <section>
277
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Error: team creation failed</h2>
278
+ <p class="text-sm text-gray-400 mb-4">Database error during org creation.</p>
279
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
280
+ ${renderErrorView("Failed to create team. Please try again.", "Try again", "/login")}
281
+ </div>
282
+ </section>
283
+
284
+ <section>
285
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Error: Slack not configured</h2>
286
+ <p class="text-sm text-gray-400 mb-4">Missing SLACK_CLIENT_ID env var.</p>
287
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
288
+ ${renderErrorView("Slack integration not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.", "Back to dashboard", "/dashboard")}
289
+ </div>
290
+ </section>
291
+
292
+ <section>
293
+ <h2 class="text-lg font-bold text-gray-700 mb-1">Error: Slack OAuth failed</h2>
294
+ <p class="text-sm text-gray-400 mb-4">Slack rejected the OAuth flow.</p>
295
+ <div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
296
+ ${renderErrorView("Slack connection failed: invalid_code", "Back to dashboard", "/dashboard")}
297
+ </div>
298
+ </section>
299
+ </div>
300
+ </div>
301
+ `, "Polaris - Preview");
302
+ });
303
+
304
+ // --- Slack OAuth ---
305
+
306
+ app.get("/slack/install", async (c) => {
307
+ const token = c.req.query("token");
308
+ const slackClientId = process.env.SLACK_CLIENT_ID;
309
+ if (!slackClientId) {
310
+ return layout(renderErrorView("Slack integration not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.", "Back to dashboard", `/dashboard?token=${token}`));
311
+ }
312
+ const state = `${token}`;
313
+ const scopes = "channels:manage,channels:join,chat:write,channels:read,users:read,users:read.email";
314
+ const url = `https://slack.com/oauth/v2/authorize?client_id=${slackClientId}&scope=${scopes}&state=${state}&redirect_uri=${encodeURIComponent(process.env.SLACK_REDIRECT_URI ?? "http://localhost:3000/slack/callback")}`;
315
+ return c.redirect(url);
316
+ });
317
+
318
+ app.get("/slack/callback", async (c) => {
319
+ const code = c.req.query("code");
320
+ const state = c.req.query("state");
321
+ if (!code || !state) return layout(renderErrorView("Missing code or state.", "Back to dashboard", "/login"));
322
+
323
+ const payload = await verifyToken(state);
324
+ if (!payload) return c.redirect("/login");
325
+
326
+ const slackRes = await fetch("https://slack.com/api/oauth.v2.access", {
327
+ method: "POST",
328
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
329
+ body: new URLSearchParams({
330
+ client_id: process.env.SLACK_CLIENT_ID ?? "",
331
+ client_secret: process.env.SLACK_CLIENT_SECRET ?? "",
332
+ code,
333
+ redirect_uri: process.env.SLACK_REDIRECT_URI ?? "http://localhost:3000/slack/callback",
334
+ }),
335
+ });
336
+ const slackData = (await slackRes.json()) as { ok: boolean; team?: { id: string }; access_token?: string; error?: string };
337
+
338
+ if (!slackData.ok) {
339
+ // Check if Slack is already connected (e.g., stale callback reload)
340
+ const org = await getOrg(sql, payload.org_id);
341
+ if (org?.slack_team_id) {
342
+ return c.redirect(`/dashboard?token=${state}`);
343
+ }
344
+ return layout(renderErrorView(`Slack connection failed: ${slackData.error}`, "Try again", `/slack/install?token=${state}`));
345
+ }
346
+
347
+ // Create the #polaris system channel
348
+ let systemChannelId: string | undefined;
349
+ try {
350
+ systemChannelId = await createSystemChannel(slackData.access_token!, payload.email);
351
+ await postSystemEvent(
352
+ slackData.access_token!,
353
+ systemChannelId,
354
+ `:star: *${payload.name}* connected this Slack workspace to Polaris`,
355
+ `Organization: ${payload.org_id}`
356
+ );
357
+ } catch {
358
+ // Non-fatal — Slack is connected even if channel creation fails
359
+ }
360
+
361
+ await setOrgSlack(sql, payload.org_id, slackData.team!.id, slackData.access_token!, systemChannelId);
362
+ return c.redirect(`/dashboard?token=${state}`);
363
+ });
364
+
365
+ // --- CLI auth flow ---
366
+
367
+ // CLI calls this with a local callback port. We redirect to Google SSO
368
+ // with state that encodes the CLI callback URL.
369
+ app.get("/auth/cli", async (c) => {
370
+ const port = c.req.query("port");
371
+ if (!port) return c.json({ error: "port is required" }, 400);
372
+
373
+ const google = getGoogle();
374
+ const state = crypto.randomUUID();
375
+ const codeVerifier = crypto.randomUUID();
376
+ oauthStates.set(state, { type: "login", codeVerifier, timestamp: Date.now() });
377
+ // Store CLI callback port in a separate map
378
+ cliCallbackPorts.set(state, Number(port));
379
+ const url = google.createAuthorizationURL(state, codeVerifier, ["openid", "email", "profile"]);
380
+ return c.redirect(url.toString());
381
+ });
382
+
383
+ // After Google SSO, if state has a CLI callback port, redirect token there
384
+ // (This is handled in the main /auth/google/callback by checking cliCallbackPorts)
385
+
386
+ // --- Token validation endpoint ---
387
+
388
+ app.get("/auth/token", async (c) => {
389
+ const token = c.req.query("token");
390
+ if (!token) return c.json({ error: "No token" }, 400);
391
+ const payload = await verifyToken(token);
392
+ if (!payload) return c.json({ error: "Invalid token" }, 401);
393
+ return c.json(payload);
394
+ });
395
+
396
+ return app;
397
+ }
@@ -0,0 +1,121 @@
1
+ // --- Seed data for dev, test, and /preview ---
2
+
3
+ export interface DeviceFixture {
4
+ name: string;
5
+ lastSeen: string;
6
+ os: string;
7
+ activeSession?: string;
8
+ }
9
+
10
+ export interface SessionFixture {
11
+ name: string;
12
+ project: string;
13
+ driver: string;
14
+ role: "driver" | "advisor";
15
+ description: string;
16
+ participants: Array<{ id: string; role: "driver" | "advisor" }>;
17
+ eventCount: number;
18
+ connectedSince: string;
19
+ }
20
+
21
+ export interface ProjectFixture {
22
+ name: string;
23
+ sessions: SessionFixture[];
24
+ slackChannel: string;
25
+ }
26
+
27
+ export const mockUser = {
28
+ name: "Manu Bansal",
29
+ email: "manu@lightup.ai",
30
+ participant_id: "user:manu",
31
+ org_id: "org-lightup",
32
+ };
33
+
34
+ export const mockOrg = {
35
+ id: "org-lightup",
36
+ name: "Lightup",
37
+ domain: "lightup.ai",
38
+ slack_team_id: "T0123SLACK",
39
+ slack_bot_token: null,
40
+ };
41
+
42
+ export const mockOrgNoSlack = {
43
+ ...mockOrg,
44
+ slack_team_id: null,
45
+ };
46
+
47
+ export const mockProjects: ProjectFixture[] = [
48
+ {
49
+ name: "polaris",
50
+ slackChannel: "#polaris",
51
+ sessions: [
52
+ {
53
+ name: "auth",
54
+ project: "polaris",
55
+ driver: "user:manu",
56
+ role: "driver",
57
+ description: "Google SSO + JWT auth",
58
+ participants: [
59
+ { id: "user:manu", role: "driver" },
60
+ { id: "agent:security-reviewer", role: "advisor" },
61
+ ],
62
+ eventCount: 42,
63
+ connectedSince: "2026-06-08T10:00:00.000Z",
64
+ },
65
+ {
66
+ name: "slack-bridge",
67
+ project: "polaris",
68
+ driver: "user:krishna",
69
+ role: "advisor",
70
+ description: "Slack floor integration",
71
+ participants: [
72
+ { id: "user:krishna", role: "driver" },
73
+ { id: "user:manu", role: "advisor" },
74
+ { id: "agent:test-writer", role: "advisor" },
75
+ ],
76
+ eventCount: 18,
77
+ connectedSince: "2026-06-08T11:30:00.000Z",
78
+ },
79
+ ],
80
+ },
81
+ {
82
+ name: "data-pipeline",
83
+ slackChannel: "#data-pipeline",
84
+ sessions: [
85
+ {
86
+ name: "ingestion",
87
+ project: "data-pipeline",
88
+ driver: "agent:dq-checker",
89
+ role: "advisor",
90
+ description: "S3-to-Snowflake rewrite",
91
+ participants: [
92
+ { id: "agent:dq-checker", role: "driver" },
93
+ { id: "user:manu", role: "advisor" },
94
+ ],
95
+ eventCount: 7,
96
+ connectedSince: "2026-06-08T09:15:00.000Z",
97
+ },
98
+ ],
99
+ },
100
+ ];
101
+
102
+ export const mockActiveSessions: SessionFixture[] = mockProjects
103
+ .flatMap((p) => p.sessions)
104
+ .filter((s) => s.participants.some((p) => p.id === mockUser.participant_id));
105
+
106
+ export const mockEmptySessions: SessionFixture[] = [];
107
+
108
+ export const mockDevices: DeviceFixture[] = [
109
+ {
110
+ name: "Manu's MacBook Pro",
111
+ lastSeen: "2026-06-08T18:42:00.000Z",
112
+ os: "macOS",
113
+ activeSession: "polaris/auth",
114
+ },
115
+ {
116
+ name: "Manu's iMac",
117
+ lastSeen: "2026-06-08T16:10:00.000Z",
118
+ os: "macOS",
119
+ activeSession: undefined,
120
+ },
121
+ ];
@@ -0,0 +1,68 @@
1
+ // --- Shared layout, nav, and HTML shell ---
2
+
3
+ export interface NavOpts {
4
+ userName?: string;
5
+ orgName?: string;
6
+ email?: string;
7
+ }
8
+
9
+ export function layout(body: string, title = "Polaris"): Response {
10
+ return new Response(
11
+ `<!DOCTYPE html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="utf-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1">
16
+ <title>${title}</title>
17
+ <script src="https://cdn.tailwindcss.com"></script>
18
+ <script>
19
+ tailwind.config = {
20
+ theme: {
21
+ extend: {
22
+ colors: {
23
+ polaris: { 50: '#f0f4ff', 100: '#dbe4ff', 200: '#bac8ff', 300: '#91a7ff', 400: '#748ffc', 500: '#5c7cfa', 600: '#4c6ef5', 700: '#4263eb', 800: '#3b5bdb', 900: '#364fc7' }
24
+ }
25
+ }
26
+ }
27
+ }
28
+ </script>
29
+ </head>
30
+ <body class="bg-gray-50 text-gray-900 antialiased">${body}</body></html>`,
31
+ { headers: { "Content-Type": "text/html" } }
32
+ );
33
+ }
34
+
35
+ export function nav(token?: string, opts?: NavOpts): string {
36
+ const right = token
37
+ ? `<div class="relative group">
38
+ <button class="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-gray-100 transition">
39
+ <div class="w-7 h-7 rounded-full bg-polaris-600 flex items-center justify-center text-white text-xs font-bold">${(opts?.userName ?? "U").charAt(0).toUpperCase()}</div>
40
+ <div class="text-left">
41
+ <p class="text-xs font-medium text-gray-900 leading-tight">${opts?.userName ?? ""}</p>
42
+ <p class="text-[10px] text-gray-400 leading-tight">${opts?.orgName ?? ""}</p>
43
+ </div>
44
+ <svg class="w-3.5 h-3.5 text-gray-400 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
45
+ </button>
46
+ <div class="hidden group-hover:block absolute right-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg py-1 z-50">
47
+ <div class="px-3 py-2 border-b border-gray-100">
48
+ <p class="text-xs font-medium text-gray-900">${opts?.userName ?? ""}</p>
49
+ <p class="text-[10px] text-gray-500">${opts?.email ?? ""}</p>
50
+ <p class="text-[10px] text-gray-400">${opts?.orgName ?? ""}</p>
51
+ </div>
52
+ <a href="/dashboard?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Dashboard</a>
53
+ <a href="/profile?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Profile</a>
54
+ <div class="border-t border-gray-100"></div>
55
+ <a href="/" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Log out</a>
56
+ </div>
57
+ </div>`
58
+ : `<a href="/login" class="text-sm font-medium text-polaris-700 hover:text-polaris-800">Sign in</a>`;
59
+ return `
60
+ <nav class="border-b border-gray-200 bg-white">
61
+ <div class="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
62
+ <a href="${token ? `/dashboard?token=${token}` : "/"}" class="text-lg font-bold tracking-tight text-gray-900">Polaris</a>
63
+ <div class="flex items-center gap-4">${right}</div>
64
+ </div>
65
+ </nav>`;
66
+ }
67
+
68
+ export const slackIcon = `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.124 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.52 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312zm-2.522 10.124a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.52v-2.523h2.521zm0-1.271a2.527 2.527 0 0 1-2.521-2.521 2.528 2.528 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.522h-6.313z"/></svg>`;