CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
org-sso-settings.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3a845e4 | 1 | /** |
| 2 | * Per-org enterprise SSO + SCIM settings. | |
| 3 | * | |
| 4 | * GET /orgs/:orgSlug/settings/sso — SSO config page | |
| 5 | * POST /orgs/:orgSlug/settings/sso — save SSO config | |
| 6 | * POST /orgs/:orgSlug/settings/sso/generate-scim-token — generate a new SCIM token | |
| 7 | */ | |
| 8 | ||
| 9 | import { Hono } from "hono"; | |
| 10 | import { eq, and } from "drizzle-orm"; | |
| 11 | import * as crypto from "crypto"; | |
| 12 | import { db } from "../db"; | |
| 13 | import { | |
| 14 | orgSsoConfigs, | |
| 15 | scimTokens, | |
| 16 | organizations, | |
| 17 | orgMembers, | |
| 18 | } from "../db/schema"; | |
| 19 | import { Layout } from "../views/layout"; | |
| 20 | import { requireAuth, softAuth } from "../middleware/auth"; | |
| 21 | import type { AuthEnv } from "../middleware/auth"; | |
| 22 | ||
| 23 | const orgSsoSettings = new Hono<AuthEnv>(); | |
| 24 | orgSsoSettings.use("*", softAuth); | |
| 25 | ||
| 26 | // --------------------------------------------------------------------------- | |
| 27 | // Scoped CSS | |
| 28 | // --------------------------------------------------------------------------- | |
| 29 | ||
| 30 | const orgSsoCss = ` | |
| 31 | .org-sso-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-6) var(--space-4); } | |
| 32 | ||
| 33 | .org-sso-hero { | |
| 34 | position: relative; | |
| 35 | margin-bottom: var(--space-5); | |
| 36 | padding: var(--space-5) var(--space-6); | |
| 37 | background: var(--bg-elevated); | |
| 38 | border: 1px solid var(--border); | |
| 39 | border-radius: 16px; | |
| 40 | overflow: hidden; | |
| 41 | } | |
| 42 | .org-sso-hero::before { | |
| 43 | content: ''; | |
| 44 | position: absolute; | |
| 45 | top: 0; left: 0; right: 0; height: 2px; | |
| 6fd5915 | 46 | background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%); |
| 3a845e4 | 47 | opacity: 0.7; |
| 48 | pointer-events: none; | |
| 49 | } | |
| 50 | .org-sso-title { | |
| 51 | font-family: var(--font-display); | |
| 52 | font-size: 24px; | |
| 53 | font-weight: 800; | |
| 54 | letter-spacing: -0.025em; | |
| 55 | color: var(--text-strong); | |
| 56 | margin: 0 0 6px; | |
| 57 | } | |
| 58 | .org-sso-sub { font-size: 13.5px; color: var(--text-muted); margin: 0; } | |
| 59 | ||
| 60 | .org-sso-section { | |
| 61 | margin-bottom: var(--space-5); | |
| 62 | background: var(--bg-elevated); | |
| 63 | border: 1px solid var(--border); | |
| 64 | border-radius: 14px; | |
| 65 | overflow: hidden; | |
| 66 | } | |
| 67 | .org-sso-section-head { | |
| 68 | padding: var(--space-4) var(--space-5); | |
| 69 | border-bottom: 1px solid var(--border); | |
| 70 | } | |
| 71 | .org-sso-section-title { | |
| 72 | margin: 0 0 4px; | |
| 73 | font-size: 15px; | |
| 74 | font-weight: 700; | |
| 75 | color: var(--text-strong); | |
| 76 | } | |
| 77 | .org-sso-section-sub { margin: 0; font-size: 12.5px; color: var(--text-muted); } | |
| 78 | .org-sso-section-body { padding: var(--space-4) var(--space-5); } | |
| 79 | ||
| 80 | .org-sso-field { margin-bottom: var(--space-4); } | |
| 81 | .org-sso-field:last-child { margin-bottom: 0; } | |
| 82 | .org-sso-field label { | |
| 83 | display: block; | |
| 84 | font-size: 12.5px; | |
| 85 | font-weight: 600; | |
| 86 | color: var(--text-strong); | |
| 87 | margin-bottom: 5px; | |
| 88 | font-family: var(--font-mono); | |
| 89 | } | |
| 90 | .org-sso-input { | |
| 91 | width: 100%; | |
| 92 | padding: 9px 12px; | |
| 93 | font-size: 13.5px; | |
| 94 | color: var(--text); | |
| 95 | background: var(--bg); | |
| 96 | border: 1px solid var(--border-strong); | |
| 97 | border-radius: 8px; | |
| 98 | box-sizing: border-box; | |
| 99 | font-family: var(--font-mono); | |
| 100 | outline: none; | |
| 101 | } | |
| 102 | .org-sso-input:focus { | |
| 103 | border-color: var(--border-focus); | |
| 6fd5915 | 104 | box-shadow: 0 0 0 3px rgba(91,110,232,0.18); |
| 3a845e4 | 105 | } |
| 106 | .org-sso-textarea { | |
| 107 | min-height: 120px; | |
| 108 | resize: vertical; | |
| 109 | } | |
| 110 | .org-sso-hint { font-size: 11.5px; color: var(--text-muted); margin-top: 5px; } | |
| 111 | ||
| 112 | .org-sso-toggle { | |
| 113 | display: flex; | |
| 114 | gap: 10px; | |
| 115 | align-items: flex-start; | |
| 116 | padding: 10px 12px; | |
| 117 | background: rgba(255,255,255,0.025); | |
| 118 | border: 1px solid var(--border); | |
| 119 | border-radius: 10px; | |
| 120 | margin-bottom: var(--space-4); | |
| 121 | } | |
| 122 | .org-sso-toggle input { margin-top: 2px; flex-shrink: 0; } | |
| 123 | .org-sso-toggle span { font-size: 13px; color: var(--text); line-height: 1.45; } | |
| 124 | ||
| 125 | .org-sso-foot { | |
| 126 | padding: var(--space-3) var(--space-5); | |
| 127 | border-top: 1px solid var(--border); | |
| 128 | display: flex; | |
| 129 | justify-content: space-between; | |
| 130 | align-items: center; | |
| 131 | gap: var(--space-2); | |
| 132 | } | |
| 133 | .org-sso-foot-hint { font-size: 12.5px; color: var(--text-muted); } | |
| 134 | ||
| 135 | .org-sso-btn { | |
| 136 | display: inline-flex; | |
| 137 | align-items: center; | |
| 138 | gap: 8px; | |
| 139 | padding: 10px 18px; | |
| 140 | border-radius: 10px; | |
| 141 | font-size: 13.5px; | |
| 142 | font-weight: 600; | |
| 143 | border: 1px solid transparent; | |
| 144 | cursor: pointer; | |
| 145 | font: inherit; | |
| 146 | text-decoration: none; | |
| 147 | } | |
| 148 | .org-sso-btn-primary { | |
| 6fd5915 | 149 | background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%); |
| 3a845e4 | 150 | color: #fff; |
| 151 | } | |
| 152 | .org-sso-btn-primary:hover { opacity: 0.9; text-decoration: none; color: #fff; } | |
| 153 | .org-sso-btn-ghost { | |
| 154 | background: transparent; | |
| 155 | border-color: var(--border-strong); | |
| 156 | color: var(--text); | |
| 157 | } | |
| 6fd5915 | 158 | .org-sso-btn-ghost:hover { background: rgba(91,110,232,0.07); border-color: rgba(91,110,232,0.45); color: var(--text-strong); text-decoration: none; } |
| 3a845e4 | 159 | |
| 160 | .org-sso-banner { | |
| 161 | margin-bottom: var(--space-4); | |
| 162 | padding: 10px 14px; | |
| 163 | border-radius: 10px; | |
| 164 | font-size: 13.5px; | |
| 165 | border: 1px solid var(--border); | |
| 166 | } | |
| 167 | .org-sso-banner.is-ok { border-color: rgba(52,211,153,0.4); background: rgba(52,211,153,0.07); color: #bbf7d0; } | |
| 168 | .org-sso-banner.is-error { border-color: rgba(248,113,113,0.4); background: rgba(248,113,113,0.07); color: #fecaca; } | |
| 169 | ||
| 170 | .org-sso-callout { | |
| 171 | display: flex; | |
| 172 | align-items: center; | |
| 173 | gap: var(--space-3); | |
| 174 | padding: 12px 14px; | |
| 6fd5915 | 175 | background: rgba(91,110,232,0.05); |
| 176 | border: 1px dashed rgba(91,110,232,0.30); | |
| 3a845e4 | 177 | border-radius: 12px; |
| 178 | flex-wrap: wrap; | |
| 179 | margin-bottom: var(--space-4); | |
| 180 | } | |
| 181 | .org-sso-callout-label { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); flex-shrink: 0; } | |
| 182 | .org-sso-callout code { | |
| 183 | flex: 1; | |
| 184 | font-family: var(--font-mono); | |
| 185 | font-size: 12.5px; | |
| 186 | color: var(--text); | |
| 187 | background: rgba(255,255,255,0.04); | |
| 188 | border: 1px solid var(--border); | |
| 189 | padding: 6px 10px; | |
| 190 | border-radius: 8px; | |
| 191 | word-break: break-all; | |
| 192 | } | |
| 193 | ||
| 194 | .org-sso-token-box { | |
| 195 | background: var(--bg); | |
| 196 | border: 1px solid var(--border-strong); | |
| 197 | border-radius: 10px; | |
| 198 | padding: 12px 14px; | |
| 199 | font-family: var(--font-mono); | |
| 200 | font-size: 13px; | |
| 201 | color: var(--text-strong); | |
| 202 | word-break: break-all; | |
| 203 | margin-top: var(--space-3); | |
| 204 | } | |
| 205 | .org-sso-token-warning { | |
| 206 | font-size: 12px; | |
| 207 | color: #fde68a; | |
| 208 | margin-top: 8px; | |
| 209 | } | |
| 210 | ||
| 211 | .org-sso-provider-tabs { | |
| 212 | display: flex; | |
| 213 | gap: 8px; | |
| 214 | margin-bottom: var(--space-4); | |
| 215 | } | |
| 216 | .org-sso-tab { | |
| 217 | padding: 8px 16px; | |
| 218 | border-radius: 8px; | |
| 219 | font-size: 13px; | |
| 220 | font-weight: 600; | |
| 221 | border: 1px solid var(--border-strong); | |
| 222 | background: transparent; | |
| 223 | color: var(--text); | |
| 224 | cursor: pointer; | |
| 225 | font: inherit; | |
| 226 | } | |
| 227 | .org-sso-tab.is-active { | |
| 6fd5915 | 228 | background: rgba(91,110,232,0.14); |
| 229 | border-color: rgba(91,110,232,0.45); | |
| 3a845e4 | 230 | color: var(--text-strong); |
| 231 | } | |
| 232 | ||
| 233 | .org-sso-status { | |
| 234 | display: inline-flex; | |
| 235 | align-items: center; | |
| 236 | gap: 6px; | |
| 237 | padding: 3px 10px; | |
| 238 | border-radius: 9999px; | |
| 239 | font-size: 11px; | |
| 240 | font-weight: 700; | |
| 241 | letter-spacing: 0.05em; | |
| 242 | text-transform: uppercase; | |
| 243 | margin-left: var(--space-3); | |
| 244 | } | |
| 245 | .org-sso-status.is-on { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); } | |
| 246 | .org-sso-status.is-off { background: rgba(148,163,184,0.10); color: #cbd5e1; box-shadow: inset 0 0 0 1px rgba(148,163,184,0.28); } | |
| 247 | .org-sso-status .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; } | |
| 248 | `; | |
| 249 | ||
| 250 | // --------------------------------------------------------------------------- | |
| 251 | // Auth + org-admin gate | |
| 252 | // --------------------------------------------------------------------------- | |
| 253 | ||
| 254 | async function requireOrgAdmin(c: any, orgSlug: string) { | |
| 255 | const user = c.get("user"); | |
| 256 | if (!user) return null; | |
| 257 | ||
| 258 | const [org] = await db | |
| 259 | .select() | |
| 260 | .from(organizations) | |
| 261 | .where(eq(organizations.slug, orgSlug)) | |
| 262 | .limit(1); | |
| 263 | if (!org) return null; | |
| 264 | ||
| 265 | const [membership] = await db | |
| 266 | .select({ role: orgMembers.role }) | |
| 267 | .from(orgMembers) | |
| 268 | .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id))) | |
| 269 | .limit(1); | |
| 270 | ||
| 271 | // Only owners and admins can configure SSO | |
| 272 | if (!membership || (membership.role !== "owner" && membership.role !== "admin")) { | |
| 273 | return null; | |
| 274 | } | |
| 275 | ||
| 276 | return { user, org }; | |
| 277 | } | |
| 278 | ||
| 279 | // --------------------------------------------------------------------------- | |
| 280 | // GET /orgs/:orgSlug/settings/sso | |
| 281 | // --------------------------------------------------------------------------- | |
| 282 | ||
| 283 | orgSsoSettings.get("/orgs/:orgSlug/settings/sso", requireAuth, async (c) => { | |
| 284 | const { orgSlug } = c.req.param(); | |
| 285 | const ctx = await requireOrgAdmin(c, orgSlug); | |
| 286 | if (!ctx) { | |
| 287 | return c.html( | |
| 288 | <Layout title="Forbidden" user={c.get("user")}> | |
| 289 | <div style="max-width:540px;margin:80px auto;text-align:center;padding:40px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:16px"> | |
| 290 | <h2 style="font-size:20px;margin:0 0 8px;color:var(--text-strong)">403 — Access denied</h2> | |
| 291 | <p style="color:var(--text-muted);margin:0">Only org owners and admins can configure SSO.</p> | |
| 292 | </div> | |
| 293 | </Layout>, | |
| 294 | 403 | |
| 295 | ); | |
| 296 | } | |
| 297 | ||
| 298 | const { user, org } = ctx; | |
| 299 | const success = c.req.query("success"); | |
| 300 | const error = c.req.query("error"); | |
| 301 | const newToken = c.req.query("token"); // plaintext token shown once after generation | |
| 302 | ||
| 303 | const [cfg] = await db | |
| 304 | .select() | |
| 305 | .from(orgSsoConfigs) | |
| 306 | .where(eq(orgSsoConfigs.orgId, org.id)) | |
| 307 | .limit(1); | |
| 308 | ||
| 309 | const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com"; | |
| 310 | const spMetadataUrl = `${base}/sso/saml/${orgSlug}/metadata`; | |
| 311 | const spAcsUrl = `${base}/sso/saml/${orgSlug}/callback`; | |
| 312 | const oidcCallbackUrl = `${base}/sso/oidc/${orgSlug}/callback`; | |
| 313 | const scimBaseUrl = `${base}/scim/v2/${org.id}`; | |
| 314 | ||
| 315 | const activeProvider = cfg?.provider || "saml"; | |
| 316 | const isEnabled = !!cfg?.enabled; | |
| 317 | ||
| 318 | return c.html( | |
| 319 | <Layout title={`SSO Settings — ${org.name}`} user={user}> | |
| 320 | <style dangerouslySetInnerHTML={{ __html: orgSsoCss }} /> | |
| 321 | <div class="org-sso-wrap"> | |
| 322 | <div class="org-sso-hero"> | |
| 323 | <h1 class="org-sso-title"> | |
| 324 | Enterprise SSO | |
| 325 | <span class={`org-sso-status ${isEnabled ? "is-on" : "is-off"}`}> | |
| 326 | <span class="dot" /> | |
| 327 | {isEnabled ? "Enabled" : "Disabled"} | |
| 328 | </span> | |
| 329 | </h1> | |
| 330 | <p class="org-sso-sub"> | |
| 331 | Configure SAML 2.0 or OIDC single sign-on and SCIM provisioning for <strong>{org.name}</strong>. | |
| 332 | Members from <code style="font-size:12px;background:var(--bg-tertiary);padding:1px 4px;border-radius:4px">{cfg?.domainHint || "your domain"}</code> will be automatically routed to your identity provider. | |
| 333 | </p> | |
| 334 | </div> | |
| 335 | ||
| 336 | {success && <div class="org-sso-banner is-ok">{decodeURIComponent(success)}</div>} | |
| 337 | {error && <div class="org-sso-banner is-error">{decodeURIComponent(error)}</div>} | |
| 338 | {newToken && ( | |
| 339 | <div class="org-sso-banner is-ok"> | |
| 340 | SCIM token generated. Copy it now — it won't be shown again. | |
| 341 | <div class="org-sso-token-box">{newToken}</div> | |
| 342 | <div class="org-sso-token-warning">Store this token securely. It grants full SCIM access to your org.</div> | |
| 343 | </div> | |
| 344 | )} | |
| 345 | ||
| 346 | <form method="post" action={`/orgs/${orgSlug}/settings/sso`}> | |
| 347 | <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) ?? ""} /> | |
| 348 | ||
| 349 | {/* Provider tabs */} | |
| 350 | <div class="org-sso-section"> | |
| 351 | <header class="org-sso-section-head"> | |
| 352 | <h2 class="org-sso-section-title">SSO Provider</h2> | |
| 353 | <p class="org-sso-section-sub">Choose your identity protocol. SAML 2.0 is most common for enterprise IdPs (Okta, Azure AD, PingFederate). OIDC works with Google Workspace, Auth0, and modern Okta.</p> | |
| 354 | </header> | |
| 355 | <div class="org-sso-section-body"> | |
| 356 | <div class="org-sso-toggle"> | |
| 357 | <input | |
| 358 | type="checkbox" | |
| 359 | name="enabled" | |
| 360 | value="1" | |
| 361 | checked={isEnabled} | |
| 362 | id="enabled" | |
| 363 | aria-label="Enable SSO for this organization" | |
| 364 | /> | |
| 365 | <span> | |
| 366 | <strong style="color:var(--text-strong)">Enable SSO.</strong> | |
| 367 | {" "}When enabled, users with a matching domain hint are automatically redirected to your IdP instead of the password form. | |
| 368 | </span> | |
| 369 | </div> | |
| 370 | ||
| 371 | <div class="org-sso-field"> | |
| 372 | <label for="provider">Provider protocol</label> | |
| 373 | <select id="provider" name="provider" class="org-sso-input" style="cursor:pointer"> | |
| 374 | <option value="saml" selected={activeProvider === "saml"}>SAML 2.0</option> | |
| 375 | <option value="oidc" selected={activeProvider === "oidc"}>OIDC / OAuth 2.0</option> | |
| 376 | </select> | |
| 377 | </div> | |
| 378 | ||
| 379 | <div class="org-sso-field"> | |
| 380 | <label for="domain_hint">Domain hint</label> | |
| 381 | <input | |
| 382 | id="domain_hint" | |
| 383 | name="domain_hint" | |
| 384 | type="text" | |
| 385 | class="org-sso-input" | |
| 386 | placeholder="acme.com" | |
| 387 | value={cfg?.domainHint || ""} | |
| 388 | /> | |
| 389 | <p class="org-sso-hint">When a user logs in with an email from this domain, they'll be redirected to SSO automatically. Leave blank to require manual SSO initiation.</p> | |
| 390 | </div> | |
| 391 | </div> | |
| 392 | </div> | |
| 393 | ||
| 394 | {/* SAML config */} | |
| 395 | <div class="org-sso-section"> | |
| 396 | <header class="org-sso-section-head"> | |
| 397 | <h2 class="org-sso-section-title">SAML 2.0 Configuration</h2> | |
| 398 | <p class="org-sso-section-sub">Provide these SP details to your IdP, then paste the IdP metadata fields below.</p> | |
| 399 | </header> | |
| 400 | <div class="org-sso-section-body"> | |
| 401 | <div class="org-sso-callout"> | |
| 402 | <span class="org-sso-callout-label">SP Metadata</span> | |
| 403 | <code>{spMetadataUrl}</code> | |
| 404 | <a href={spMetadataUrl} target="_blank" class="org-sso-btn org-sso-btn-ghost" style="padding:6px 12px;font-size:12px">Download</a> | |
| 405 | </div> | |
| 406 | <div class="org-sso-callout"> | |
| 407 | <span class="org-sso-callout-label">ACS URL</span> | |
| 408 | <code>{spAcsUrl}</code> | |
| 409 | </div> | |
| 410 | ||
| 411 | <div class="org-sso-field"> | |
| 412 | <label for="idp_entity_id">IdP Entity ID</label> | |
| 413 | <input id="idp_entity_id" name="idp_entity_id" type="text" class="org-sso-input" placeholder="https://idp.example.com/saml/metadata" value={cfg?.idpEntityId || ""} /> | |
| 414 | </div> | |
| 415 | <div class="org-sso-field"> | |
| 416 | <label for="idp_sso_url">IdP SSO URL</label> | |
| 417 | <input id="idp_sso_url" name="idp_sso_url" type="text" class="org-sso-input" placeholder="https://idp.example.com/saml/sso" value={cfg?.idpSsoUrl || ""} /> | |
| 418 | <p class="org-sso-hint">The HTTP-Redirect or HTTP-POST binding URL from your IdP's metadata.</p> | |
| 419 | </div> | |
| 420 | <div class="org-sso-field"> | |
| 421 | <label for="idp_certificate">IdP X.509 Certificate (PEM)</label> | |
| 422 | <textarea id="idp_certificate" name="idp_certificate" class="org-sso-input org-sso-textarea" placeholder="-----BEGIN CERTIFICATE----- MIIC... -----END CERTIFICATE-----">{cfg?.idpCertificate || ""}</textarea> | |
| 423 | <p class="org-sso-hint">Paste the full PEM-encoded certificate from your IdP. Used to verify SAML assertion signatures.</p> | |
| 424 | </div> | |
| 425 | </div> | |
| 426 | </div> | |
| 427 | ||
| 428 | {/* OIDC config */} | |
| 429 | <div class="org-sso-section"> | |
| 430 | <header class="org-sso-section-head"> | |
| 431 | <h2 class="org-sso-section-title">OIDC / OAuth 2.0 Configuration</h2> | |
| 432 | <p class="org-sso-section-sub">Fill these if your provider uses OIDC (OpenID Connect).</p> | |
| 433 | </header> | |
| 434 | <div class="org-sso-section-body"> | |
| 435 | <div class="org-sso-callout"> | |
| 436 | <span class="org-sso-callout-label">Redirect URI</span> | |
| 437 | <code>{oidcCallbackUrl}</code> | |
| 438 | </div> | |
| 439 | ||
| 440 | <div class="org-sso-field"> | |
| 441 | <label for="oidc_discovery_url">OIDC Discovery / Issuer URL</label> | |
| 442 | <input id="oidc_discovery_url" name="oidc_discovery_url" type="text" class="org-sso-input" placeholder="https://accounts.google.com or https://YOUR-TENANT.okta.com" value={cfg?.oidcDiscoveryUrl || ""} /> | |
| 443 | <p class="org-sso-hint">The base issuer URL — we'll append <code>/.well-known/openid-configuration</code> automatically.</p> | |
| 444 | </div> | |
| 445 | <div class="org-sso-field"> | |
| 446 | <label for="oidc_client_id">Client ID</label> | |
| 447 | <input id="oidc_client_id" name="oidc_client_id" type="text" class="org-sso-input" autocomplete="off" value={cfg?.oidcClientId || ""} /> | |
| 448 | </div> | |
| 449 | <div class="org-sso-field"> | |
| 450 | <label for="oidc_client_secret">Client Secret</label> | |
| 451 | <input id="oidc_client_secret" name="oidc_client_secret" type="password" class="org-sso-input" autocomplete="off" placeholder={cfg?.oidcClientSecret ? "(stored — leave blank to keep)" : ""} /> | |
| 452 | </div> | |
| 453 | </div> | |
| 454 | </div> | |
| 455 | ||
| 456 | <div class="org-sso-section"> | |
| 457 | <div class="org-sso-foot"> | |
| 458 | <span class="org-sso-foot-hint"> | |
| 459 | <a href={`/sso/${activeProvider === "saml" ? "saml" : "oidc"}/${orgSlug}/login`} target="_blank" style="color:var(--accent);text-decoration:none">Test SSO</a> after saving. | |
| 460 | </span> | |
| 461 | <button type="submit" class="org-sso-btn org-sso-btn-primary">Save SSO settings</button> | |
| 462 | </div> | |
| 463 | </div> | |
| 464 | </form> | |
| 465 | ||
| 466 | {/* SCIM section */} | |
| 467 | <div class="org-sso-section"> | |
| 468 | <header class="org-sso-section-head"> | |
| 469 | <h2 class="org-sso-section-title">SCIM User Provisioning</h2> | |
| 470 | <p class="org-sso-section-sub"> | |
| 471 | Connect your IdP (Okta, Azure AD, Google Workspace) to automatically provision and deprovision members of <strong>{org.name}</strong>. | |
| 472 | </p> | |
| 473 | </header> | |
| 474 | <div class="org-sso-section-body"> | |
| 475 | <div class="org-sso-callout"> | |
| 476 | <span class="org-sso-callout-label">SCIM Base URL</span> | |
| 477 | <code>{scimBaseUrl}</code> | |
| 478 | </div> | |
| 479 | <p class="org-sso-hint" style="margin-bottom:var(--space-4)"> | |
| 480 | Configure your IdP with the SCIM base URL above and a bearer token generated below. Set the SCIM version to 2.0 in your IdP. | |
| 481 | </p> | |
| 482 | </div> | |
| 483 | <div class="org-sso-foot"> | |
| 484 | <span class="org-sso-foot-hint">Tokens are shown once at creation. Store them securely.</span> | |
| 485 | <form method="post" action={`/orgs/${orgSlug}/settings/sso/generate-scim-token`}> | |
| 486 | <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) ?? ""} /> | |
| 487 | <button type="submit" class="org-sso-btn org-sso-btn-ghost">Generate SCIM token</button> | |
| 488 | </form> | |
| 489 | </div> | |
| 490 | </div> | |
| 491 | </div> | |
| 492 | </Layout> | |
| 493 | ); | |
| 494 | }); | |
| 495 | ||
| 496 | // --------------------------------------------------------------------------- | |
| 497 | // POST /orgs/:orgSlug/settings/sso — save config | |
| 498 | // --------------------------------------------------------------------------- | |
| 499 | ||
| 500 | orgSsoSettings.post("/orgs/:orgSlug/settings/sso", requireAuth, async (c) => { | |
| 501 | const { orgSlug } = c.req.param(); | |
| 502 | const ctx = await requireOrgAdmin(c, orgSlug); | |
| 503 | if (!ctx) return c.redirect(`/orgs/${orgSlug}`); | |
| 504 | ||
| 505 | const { org } = ctx; | |
| 506 | const body = await c.req.parseBody(); | |
| 507 | ||
| 508 | const enabled = String(body.enabled || "") === "1"; | |
| 509 | const provider = String(body.provider || "saml"); | |
| 510 | const domainHint = String(body.domain_hint || "").trim() || null; | |
| 511 | ||
| 512 | // SAML fields | |
| 513 | const idpEntityId = String(body.idp_entity_id || "").trim() || null; | |
| 514 | const idpSsoUrl = String(body.idp_sso_url || "").trim() || null; | |
| 515 | const idpCertificate = String(body.idp_certificate || "").trim() || null; | |
| 516 | ||
| 517 | // OIDC fields | |
| 518 | const oidcDiscoveryUrl = String(body.oidc_discovery_url || "").trim() || null; | |
| 519 | const oidcClientId = String(body.oidc_client_id || "").trim() || null; | |
| 520 | ||
| 521 | // Existing config to preserve client_secret if not changed | |
| 522 | const [existing] = await db | |
| 523 | .select() | |
| 524 | .from(orgSsoConfigs) | |
| 525 | .where(eq(orgSsoConfigs.orgId, org.id)) | |
| 526 | .limit(1); | |
| 527 | ||
| 528 | const oidcClientSecretNew = String(body.oidc_client_secret || "").trim(); | |
| 529 | const oidcClientSecret = | |
| 530 | oidcClientSecretNew.length > 0 ? oidcClientSecretNew : existing?.oidcClientSecret || null; | |
| 531 | ||
| 532 | const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com"; | |
| 533 | const spEntityId = `${base}/sso/saml/${orgSlug}`; | |
| 534 | ||
| 535 | if (existing) { | |
| 536 | await db | |
| 537 | .update(orgSsoConfigs) | |
| 538 | .set({ | |
| 539 | enabled, | |
| 540 | provider, | |
| 541 | domainHint, | |
| 542 | idpEntityId, | |
| 543 | idpSsoUrl, | |
| 544 | idpCertificate, | |
| 545 | spEntityId, | |
| 546 | oidcDiscoveryUrl, | |
| 547 | oidcClientId, | |
| 548 | oidcClientSecret, | |
| 549 | updatedAt: new Date(), | |
| 550 | }) | |
| 551 | .where(eq(orgSsoConfigs.orgId, org.id)); | |
| 552 | } else { | |
| 553 | await db.insert(orgSsoConfigs).values({ | |
| 554 | orgId: org.id, | |
| 555 | enabled, | |
| 556 | provider, | |
| 557 | domainHint, | |
| 558 | idpEntityId, | |
| 559 | idpSsoUrl, | |
| 560 | idpCertificate, | |
| 561 | spEntityId, | |
| 562 | oidcDiscoveryUrl, | |
| 563 | oidcClientId, | |
| 564 | oidcClientSecret, | |
| 565 | }); | |
| 566 | } | |
| 567 | ||
| 568 | return c.redirect( | |
| 569 | `/orgs/${orgSlug}/settings/sso?success=${encodeURIComponent("SSO settings saved.")}` | |
| 570 | ); | |
| 571 | }); | |
| 572 | ||
| 573 | // --------------------------------------------------------------------------- | |
| 574 | // POST /orgs/:orgSlug/settings/sso/generate-scim-token | |
| 575 | // --------------------------------------------------------------------------- | |
| 576 | ||
| 577 | orgSsoSettings.post( | |
| 578 | "/orgs/:orgSlug/settings/sso/generate-scim-token", | |
| 579 | requireAuth, | |
| 580 | async (c) => { | |
| 581 | const { orgSlug } = c.req.param(); | |
| 582 | const ctx = await requireOrgAdmin(c, orgSlug); | |
| 583 | if (!ctx) return c.redirect(`/orgs/${orgSlug}`); | |
| 584 | ||
| 585 | const { user, org } = ctx; | |
| 586 | ||
| ef38eca | 587 | // Generate a random bearer token for SCIM provisioning. |
| 588 | // Prefix "gscim1_" identifies the token type in logs without embedding a secret. | |
| 589 | const tokenPrefix = "gscim1_"; | |
| 590 | const rawToken = tokenPrefix + crypto.randomBytes(30).toString("hex"); | |
| 3a845e4 | 591 | const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex"); |
| 592 | ||
| 593 | await db.insert(scimTokens).values({ | |
| 594 | orgId: org.id, | |
| 595 | tokenHash, | |
| 596 | createdBy: user.id, | |
| 597 | }); | |
| 598 | ||
| 599 | return c.redirect( | |
| 600 | `/orgs/${orgSlug}/settings/sso?token=${encodeURIComponent(rawToken)}&success=${encodeURIComponent("SCIM token generated. Copy it now — it won't be shown again.")}` | |
| 601 | ); | |
| 602 | } | |
| 603 | ); | |
| 604 | ||
| 605 | export default orgSsoSettings; |