Skip to content
Merged

Dev #232

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,8 @@ SENTRY_AUTH_TOKEN=your-sentry-auth-token

# Cron-protected endpoints (shared secret sent as Authorization: Bearer $CRON_SECRET)
CRON_SECRET=your-cron-secret

# Beta whitelist gate. When true, only emails in the beta_whitelist table can
# access the app. Set true locally to mirror deployed behavior; seed allowed
# emails via supabase/seed.sql.
BETA_WHITELIST_ENABLED=false
159 changes: 28 additions & 131 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,7 @@ import {
import { supabaseAdmin } from '$lib/server/supabase.js';
import { isSystemAdminEmail } from '$lib/server/system-admin.js';
import { isEmailWhitelisted } from '$lib/server/beta-whitelist.js';
import type {
OrgType,
OrganizationMember,
Organization,
AccountUser
} from '$lib/types/database.js';

type MembershipWithOrg = OrganizationMember & { organizations: Organization };
type BrandAccessRow = { brand_id: string; brands?: { name?: string } | { name?: string }[] | null };
type BuyerAccountRow = AccountUser & {
account_id: string;
accounts?: { organization_id?: string } | null;
};
type SsoIdentity = { provider?: string };
import { loadUserContext, applyUserContext } from '$lib/server/auth.js';

Sentry.init({
dsn: PUBLIC_SENTRY_DSN,
Expand Down Expand Up @@ -52,6 +39,7 @@ const PUBLIC_ROUTES = [
'/buyer-invite',
'/connect',
'/auth/callback',
'/logout',
'/upload',
'/api/dev',
'/api/beta',
Expand Down Expand Up @@ -125,132 +113,41 @@ const authHandle: Handle = async ({ event, resolve }) => {

// Load user context for authenticated routes
if (session && user && !isPublicRoute) {
// System super-admin path: above-org identity, no org/buyer context.
// Confines the session to /system/** and its API/logout escape hatches.
if (isSystemAdminEmail(user.email)) {
const { data: profile } = await supabaseAdmin
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
event.locals.user = profile;
event.locals.isSystemAdmin = true;
const path = event.url.pathname;
const allowed =
path.startsWith('/system') || path.startsWith('/api/') || path.startsWith('/logout');
if (!allowed) throw redirect(303, '/system');
return resolve(event);
}

const [{ data: profile }, { data: allMemberships }] = await Promise.all([
supabaseAdmin.from('profiles').select('*').eq('id', user.id).single(),
supabase.from('organization_members').select('*, organizations(*)').eq('profile_id', user.id)
]);

if (allMemberships?.length) {
const typedMemberships = allMemberships as MembershipWithOrg[];
event.locals.allMemberships = typedMemberships;

// Determine active org from cookie, fallback to first membership
const activeOrgId = event.cookies.get('active_org_id');
const membership = activeOrgId
? (typedMemberships.find((m) => m.organization_id === activeOrgId) ?? typedMemberships[0])
: typedMemberships[0];

// Org member path
let brandScope: string[] | null = null;
let scopedBrandNames: string[] | null = null;
if (['member', 'sales', 'guest'].includes(membership.role)) {
const { data: brandAccess } = await supabase
.from('member_brand_access')
.select('brand_id, brands(name)')
.eq('member_id', membership.id);
if (brandAccess?.length) {
const rows = brandAccess as BrandAccessRow[];
brandScope = rows.map((b) => b.brand_id);
scopedBrandNames = rows
.map((b) => {
const brand = b.brands;
if (!brand) return undefined;
if (Array.isArray(brand)) return brand[0]?.name;
return brand.name;
})
.filter((n): n is string => Boolean(n));
}
const context = await loadUserContext(
supabase,
supabaseAdmin,
user,
event.cookies.get('active_org_id')
);
applyUserContext(event.locals, context);

switch (context.kind) {
case 'system_admin': {
// Confine the system super-admin session to /system/** and its
// API/logout escape hatches.
const path = event.url.pathname;
const allowed =
path.startsWith('/system') || path.startsWith('/api/') || path.startsWith('/logout');
if (!allowed) throw redirect(303, '/system');
return resolve(event);
}

const org = membership?.organizations;
event.locals.user = profile;
event.locals.membership = membership;
event.locals.organization = org ?? null;
event.locals.orgType = (org?.org_type as OrgType) ?? 'rep';
event.locals.brandScope = brandScope;
event.locals.scopedBrandNames = scopedBrandNames;

// SSO enforcement: if org requires SSO, verify user authenticated via SSO
if (org?.sso_enforced && user.email) {
const emailDomain = user.email.split('@')[1]?.toLowerCase();
if (emailDomain) {
const { data: ssoProvider } = await supabaseAdmin
.from('organization_sso_providers')
.select('id')
.eq('organization_id', org.id)
.eq('domain', emailDomain)
.limit(1)
.single();

if (ssoProvider) {
const isSsoSession =
user.app_metadata?.provider === 'sso' ||
user.identities?.some((i: SsoIdentity) => i.provider === 'sso');
if (!isSsoSession) {
await supabase.auth.signOut();
throw redirect(303, '/login?error=sso_required');
}
}
case 'org_member': {
// SSO enforcement: org requires SSO but session isn't an SSO session.
if (context.ssoRequired) {
await supabase.auth.signOut();
throw redirect(303, '/login?error=sso_required');
}
break;
}
} else {
// Check if user is a buyer
const { data: buyerAccess } = await supabase
.from('account_users')
.select('*, accounts(*, organizations(*))')
.eq('profile_id', user.id);

if (buyerAccess?.length) {
const typedBuyerAccess = buyerAccess as BuyerAccountRow[];
event.locals.user = profile;
event.locals.isBuyer = true;
event.locals.buyerAccounts = typedBuyerAccess;

// Load accessible brand IDs (use admin client to bypass RLS)
const accountIds = typedBuyerAccess.map((a) => a.account_id);
const { data: brandAccess } = await supabaseAdmin
.from('account_brand_access')
.select('brand_id')
.in('account_id', accountIds);
event.locals.buyerBrandIds =
(brandAccess as Array<{ brand_id: string }> | null)?.map((b) => b.brand_id) ?? null;

// Set organization from the account's org (use admin to bypass RLS)
const orgId = typedBuyerAccess[0]?.accounts?.organization_id;
if (orgId) {
const { data: org } = await supabaseAdmin
.from('organizations')
.select('*')
.eq('id', orgId)
.single();
if (org) event.locals.organization = org;
}
} else {
// No org membership and not a buyer — redirect to onboarding
event.locals.user = profile;
case 'onboarding': {
// No org membership and not a buyer — redirect to onboarding.
if (
!event.url.pathname.startsWith('/onboarding') &&
!event.url.pathname.startsWith('/api/')
) {
throw redirect(303, '/onboarding');
}
break;
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/components/layout/navbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@

async function signOut() {
await supabase.auth.signOut();
// Clear the httpOnly active_org_id cookie server-side; client signOut can't.
await fetch('/logout', { method: 'POST' });
goto(resolve('/login'));
}
</script>
Expand Down
2 changes: 0 additions & 2 deletions src/lib/components/marketing/CommandBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,6 @@
padding: 6px 8px;
border-radius: 6px;
}
:global(.cmd-row.hl) {
}
:global(.cmd-row-left) {
display: flex;
align-items: center;
Expand Down
3 changes: 1 addition & 2 deletions src/lib/components/marketing/MarketingNav.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { resolve } from '$app/paths';

const isAuthenticated = $derived(!!$page.data.session);
const isBeta = $derived($page.url.hostname === 'beta.threadline.systems');
</script>

<header>
Expand Down Expand Up @@ -31,7 +30,7 @@
>
<a
class="rounded-lg bg-foreground px-5 py-2.5 text-sm text-primary-foreground"
href={resolve(isBeta ? '/beta' : '/login')}>Join Beta</a
href={resolve('/beta')}>Join Beta</a
>
{/if}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/lib/components/marketing/MessagingPhone.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@
{#if hasPlayed}
<button
onclick={play}
aria-label="Replay"
class="absolute right-3 bottom-3 flex h-8 w-8 items-center justify-center rounded-full bg-indigo-500 text-white transition-transform hover:bg-indigo-600"
>
<svg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@
<div class="relative">
<button
type="button"
aria-label="Toggle spotlight options"
class="inline-flex h-5 w-4 cursor-pointer items-center justify-center rounded-r-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
onclick={() => (spotlightMenuOpen = !spotlightMenuOpen)}
>
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/products/CreateProductForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@
>
<Checkbox bind:checked={$form.ats} />
<div>
<label class="text-sm font-medium">Available to ship (ATS)</label>
<span class="text-sm font-medium">Available to ship (ATS)</span>
<p class="mt-0.5 text-sm text-muted-foreground">
In stock and shippable now. Turn off for futures or pre-orders. Inventory inputs
only appear when this is on.
Expand All @@ -419,7 +419,7 @@
>
<Checkbox bind:checked={$form.featured} />
<div>
<label class="text-sm font-medium">Featured</label>
<span class="text-sm font-medium">Featured</span>
<p class="mt-0.5 text-sm text-muted-foreground">
Surfaces on the brand homepage and in seasonal pickers.
</p>
Expand Down
16 changes: 13 additions & 3 deletions src/lib/components/products/VariantMatrix.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -198,17 +198,27 @@
<tr class="border-b border-border/50">
<td class="px-3 py-3.5">
<div class="flex items-center gap-2.5">
<div
class="relative h-6 w-6 shrink-0 overflow-hidden rounded border border-border {onChangeColorHex
<button
type="button"
disabled={!onChangeColorHex}
class="relative h-6 w-6 shrink-0 overflow-hidden rounded border border-border p-0 {onChangeColorHex
? 'cursor-pointer'
: ''}"
style:background={group.colorHex ?? '#f5f5f5'}
aria-label={onChangeColorHex ? `Change color for ${group.color}` : undefined}
ondblclick={() => {
if (onChangeColorHex) {
if (colorPickerRef) colorPickerRef.value = group.colorHex ?? '#000000';
openColorPicker(group.color);
}
}}
onkeydown={(e) => {
if (onChangeColorHex && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
if (colorPickerRef) colorPickerRef.value = group.colorHex ?? '#000000';
openColorPicker(group.color);
}
}}
>
{#if !group.colorHex}
<div class="absolute inset-0">
Expand All @@ -217,7 +227,7 @@
</svg>
</div>
{/if}
</div>
</button>
<span
class="cursor-text font-medium outline-none"
contenteditable={editingColor !== false && editingColor === group.color
Expand Down
15 changes: 9 additions & 6 deletions src/lib/components/products/VariantRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,12 @@
<div class="border-t border-border px-3.5 pt-3.5 pb-4">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1.5 block text-sm font-medium">Color name</label>
<label class="mb-1.5 block text-sm font-medium" for="color-name-{variant.id}"
>Color name</label
>
<input
bind:this={colorInput}
id="color-name-{variant.id}"
type="text"
class="w-full border border-border bg-background px-2.5 py-2 text-sm"
placeholder="e.g. Camel"
Expand All @@ -104,9 +107,9 @@
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium">
<span class="mb-1.5 block text-sm font-medium">
Hex <span class="text-sm font-normal text-muted-foreground">(optional)</span>
</label>
</span>
<div class="flex border border-border bg-card">
<input
type="color"
Expand Down Expand Up @@ -134,9 +137,9 @@
</div>

<div class="mt-4">
<label class="mb-1.5 block text-sm font-medium">
<span class="mb-1.5 block text-sm font-medium">
Images <span class="text-sm font-normal text-muted-foreground">(optional)</span>
</label>
</span>
<ImagePair
primaryFile={variant.images.primary}
hoverFile={variant.images.hover}
Expand All @@ -150,7 +153,7 @@

{#if ats}
<div class="mt-4">
<label class="mb-1.5 block text-sm font-medium">Inventory</label>
<span class="mb-1.5 block text-sm font-medium">Inventory</span>
<InventoryMatrix
{sizes}
skuPrefix={styleNumber}
Expand Down
Loading
Loading