mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
fix
This commit is contained in:
@@ -1,980 +0,0 @@
|
||||
# Onboarding Channel Config + Style Refactor Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a two-phase provider-select → configure flow to the notifier and storage onboarding steps (fake data, stored in flowData), restyle all toggle cards/buttons across onboarding steps to use design-system tokens matching organization-combobox.tsx, and wire conditional step skipping when no agents are created.
|
||||
|
||||
**Architecture:** `step-notifier` and `step-storage` manage a local `phase` state (`"grid"` | `"configuring"`). In "configuring" phase they render a `<Form>` using the existing `NotificationChannelFormSchema`/`StorageChannelFormSchema` and `renderChannelForm(provider, form)`. On submit, the configured channel `{ id, provider, label, name, config }` is appended to local state; no server action is called. All toggle `<button>` elements across steps replace hardcoded `border-white/10` with design-system tokens (`border-border`, `border-primary/20`, `bg-primary/10`, `hover:bg-accent/50`). The `agent-create` step gets a dynamic `nextStep` function that skips `agent-waiting`, `project-create`, and `db-settings` when `flowData.agents` is empty.
|
||||
|
||||
**Tech Stack:** Next.js App Router, `@onboardjs/react` 1.0.0-rc.5, `react-hook-form` + zod via `useZodForm`/`Form` from `@/components/ui/form`, existing `renderChannelForm` from `@/features/channel/channels-helpers`, `NotificationChannelFormSchema`/`StorageChannelFormSchema` from `@/features/channel/channel-form.schema`, shadcn/radix UI, lucide-react.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/features/onboarding/onboarding.types.ts` | Modify | Extend `OnboardingChannel` with `name` + `config` fields |
|
||||
| `src/features/onboarding/steps/step-notifier.tsx` | Rewrite | Two-phase grid→config flow for notification providers |
|
||||
| `src/features/onboarding/steps/step-storage.tsx` | Rewrite | Two-phase grid→config flow for storage providers |
|
||||
| `src/features/onboarding/steps/step-sso-gate.tsx` | Modify | Restyle SSO provider buttons to org-combobox card style |
|
||||
| `src/features/onboarding/steps/step-security.tsx` | Modify | Restyle passkey/2FA choice to org-combobox card style |
|
||||
| `src/features/onboarding/steps/step-preferences.tsx` | Modify | Restyle theme toggle buttons to org-combobox card style |
|
||||
| `src/features/onboarding/steps/step-project-create.tsx` | Modify | Replace `border-white/10` with `border-border` on DB toggles |
|
||||
| `src/features/onboarding/onboarding-steps.tsx` | Modify | Change `agent-create.nextStep` to a conditional function |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extend `OnboardingChannel` type
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/onboarding/onboarding.types.ts`
|
||||
|
||||
- [ ] **Step 1: Update `OnboardingChannel`**
|
||||
|
||||
Replace lines 31–35 in `src/features/onboarding/onboarding.types.ts`:
|
||||
|
||||
```typescript
|
||||
export type OnboardingChannel = {
|
||||
id: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: only the existing `step-account-info.tsx:28` better-auth error — `step-notifier.tsx` and `step-storage.tsx` will error until rewritten in Tasks 2 and 3, which is expected. No errors in `onboarding.types.ts` itself.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/onboarding.types.ts
|
||||
git commit -m "feat(onboarding): extend OnboardingChannel with name and config fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite `step-notifier.tsx` — two-phase config flow + style
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `src/features/onboarding/steps/step-notifier.tsx`
|
||||
|
||||
**Context:** This step manages a `phase` local state. In `"grid"` phase it shows the provider selection grid. When the user clicks a provider, `phase` switches to `"configuring"` with the selected provider ID. In `"configuring"` phase it renders a full form using `NotificationChannelFormSchema` and `renderChannelForm`. On valid submit the channel is added to a local `channels` list and phase returns to `"grid"`. Clicking Continue saves `channels` to `flowData.notifiers` and calls `next()`. Provider cards use org-combobox design tokens. No server action is called.
|
||||
|
||||
- [ ] **Step 1: Write the file**
|
||||
|
||||
```typescript
|
||||
// src/features/onboarding/steps/step-notifier.tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { ArrowLeft, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
useZodForm,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { notificationProviders } from "@/features/channel/channels-notification-helper";
|
||||
import { renderChannelForm } from "@/features/channel/channels-helpers";
|
||||
import { NotificationChannelFormSchema } from "@/features/channel/channel-form.schema";
|
||||
import { OnboardingChannel } from "@/features/onboarding/onboarding.types";
|
||||
|
||||
type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
|
||||
|
||||
export const StepNotifier = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "grid" });
|
||||
const [channels, setChannels] = useState<OnboardingChannel[]>([]);
|
||||
|
||||
// @ts-expect-error — discriminated union schema, provider set on phase entry
|
||||
const form = useZodForm({ schema: NotificationChannelFormSchema });
|
||||
|
||||
const startConfiguring = (provider: string) => {
|
||||
// @ts-expect-error — discriminated union
|
||||
form.reset({ provider, enabled: true, name: "", config: {} });
|
||||
setPhase({ kind: "configuring", provider });
|
||||
};
|
||||
|
||||
const removeChannel = (id: string) => {
|
||||
setChannels((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, notifiers: channels } });
|
||||
await next();
|
||||
};
|
||||
|
||||
if (phase.kind === "configuring") {
|
||||
const providerDetails = notificationProviders.find((p) => p.value === phase.provider);
|
||||
const Icon = providerDetails?.icon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-secondary/30 rounded-lg border border-border">
|
||||
{Icon && (
|
||||
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
<p className="flex-1 text-sm font-medium">
|
||||
Configuring {providerDetails?.label}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPhase({ kind: "grid" })}
|
||||
>
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values: any) => {
|
||||
const details = notificationProviders.find(
|
||||
(p) => p.value === values.provider
|
||||
);
|
||||
setChannels((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
provider: values.provider,
|
||||
label: details?.label ?? values.provider,
|
||||
name: values.name,
|
||||
config: values.config as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
// @ts-expect-error — discriminated union
|
||||
form.reset({ enabled: true });
|
||||
setPhase({ kind: "grid" });
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder={`e.g. ${providerDetails?.label ?? ""} alerts`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<input type="hidden" {...field} value={field.value || ""} />
|
||||
)}
|
||||
/>
|
||||
{renderChannelForm(phase.provider, form)}
|
||||
<Button type="submit">Add channel</Button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Phase: grid
|
||||
const configuredProviderIds = channels.map((c) => c.provider);
|
||||
const availableProviders = notificationProviders.filter((p) => !p.preview);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Connect a notifier</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Optional — get notified about backups, restores and health checks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{channels.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{channels.map((ch) => {
|
||||
const details = notificationProviders.find((p) => p.value === ch.provider);
|
||||
const Icon = details?.icon;
|
||||
return (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/10 p-2 text-sm text-primary"
|
||||
>
|
||||
{Icon && (
|
||||
<div className="size-7 rounded-md border bg-muted/50 flex items-center justify-center shrink-0">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-1 truncate">
|
||||
{ch.name}{" "}
|
||||
<span className="opacity-60">({ch.label})</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeChannel(ch.id)}
|
||||
className="opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableProviders.map((provider) => {
|
||||
const Icon = provider.icon;
|
||||
const isConfigured = configuredProviderIds.includes(provider.value);
|
||||
return (
|
||||
<button
|
||||
key={provider.value}
|
||||
type="button"
|
||||
onClick={() => startConfiguring(provider.value)}
|
||||
className={`flex items-center gap-2 rounded-lg border p-2 text-sm transition-colors ${
|
||||
isConfigured
|
||||
? "border-primary/20 bg-primary/10 text-primary"
|
||||
: "border-border hover:bg-accent/50 hover:border-primary/20"
|
||||
}`}
|
||||
>
|
||||
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">{provider.label}</span>
|
||||
{isConfigured && (
|
||||
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto">
|
||||
<Check className="size-3 text-primary-foreground" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: no errors in `step-notifier.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/steps/step-notifier.tsx
|
||||
git commit -m "feat(onboarding): add two-phase config flow and org-combobox style to notifier step"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewrite `step-storage.tsx` — two-phase config flow + style
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `src/features/onboarding/steps/step-storage.tsx`
|
||||
|
||||
**Context:** Identical pattern to Task 2 but for storage providers. Use `StorageChannelFormSchema` and `storageProviders`. Filter out `p.value === "local"` from the grid (local storage has no config form). `renderChannelForm("s3", form)` etc. renders the correct sub-form from `channels-helpers.tsx`.
|
||||
|
||||
- [ ] **Step 1: Write the file**
|
||||
|
||||
```typescript
|
||||
// src/features/onboarding/steps/step-storage.tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { ArrowLeft, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
useZodForm,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { storageProviders } from "@/features/channel/channels-storage-helper";
|
||||
import { renderChannelForm } from "@/features/channel/channels-helpers";
|
||||
import { StorageChannelFormSchema } from "@/features/channel/channel-form.schema";
|
||||
import { OnboardingChannel } from "@/features/onboarding/onboarding.types";
|
||||
|
||||
type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
|
||||
|
||||
export const StepStorage = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "grid" });
|
||||
const [channels, setChannels] = useState<OnboardingChannel[]>([]);
|
||||
|
||||
// @ts-expect-error — discriminated union schema, provider set on phase entry
|
||||
const form = useZodForm({ schema: StorageChannelFormSchema });
|
||||
|
||||
const startConfiguring = (provider: string) => {
|
||||
// @ts-expect-error — discriminated union
|
||||
form.reset({ provider, enabled: true, name: "", config: {} });
|
||||
setPhase({ kind: "configuring", provider });
|
||||
};
|
||||
|
||||
const removeChannel = (id: string) => {
|
||||
setChannels((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, storages: channels } });
|
||||
await next();
|
||||
};
|
||||
|
||||
if (phase.kind === "configuring") {
|
||||
const providerDetails = storageProviders.find((p) => p.value === phase.provider);
|
||||
const Icon = providerDetails?.icon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-secondary/30 rounded-lg border border-border">
|
||||
{Icon && (
|
||||
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
<p className="flex-1 text-sm font-medium">
|
||||
Configuring {providerDetails?.label}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPhase({ kind: "grid" })}
|
||||
>
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values: any) => {
|
||||
const details = storageProviders.find(
|
||||
(p) => p.value === values.provider
|
||||
);
|
||||
setChannels((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
provider: values.provider,
|
||||
label: details?.label ?? values.provider,
|
||||
name: values.name,
|
||||
config: values.config as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
// @ts-expect-error — discriminated union
|
||||
form.reset({ enabled: true });
|
||||
setPhase({ kind: "grid" });
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder={`e.g. ${providerDetails?.label ?? ""} backup`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<input type="hidden" {...field} value={field.value || ""} />
|
||||
)}
|
||||
/>
|
||||
{renderChannelForm(phase.provider, form)}
|
||||
<Button type="submit">Add storage</Button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Phase: grid — filter "local" (no config form, no credentials)
|
||||
const availableProviders = storageProviders.filter(
|
||||
(p) => !p.preview && p.value !== "local"
|
||||
);
|
||||
const configuredProviderIds = channels.map((c) => c.provider);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Connect a storage</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Optional — choose where backups and files are stored.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{channels.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{channels.map((ch) => {
|
||||
const details = storageProviders.find((p) => p.value === ch.provider);
|
||||
const Icon = details?.icon;
|
||||
return (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/10 p-2 text-sm text-primary"
|
||||
>
|
||||
{Icon && (
|
||||
<div className="size-7 rounded-md border bg-muted/50 flex items-center justify-center shrink-0">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-1 truncate">
|
||||
{ch.name}{" "}
|
||||
<span className="opacity-60">({ch.label})</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeChannel(ch.id)}
|
||||
className="opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableProviders.map((provider) => {
|
||||
const Icon = provider.icon;
|
||||
const isConfigured = configuredProviderIds.includes(provider.value);
|
||||
return (
|
||||
<button
|
||||
key={provider.value}
|
||||
type="button"
|
||||
onClick={() => startConfiguring(provider.value)}
|
||||
className={`flex items-center gap-2 rounded-lg border p-2 text-sm transition-colors ${
|
||||
isConfigured
|
||||
? "border-primary/20 bg-primary/10 text-primary"
|
||||
: "border-border hover:bg-accent/50 hover:border-primary/20"
|
||||
}`}
|
||||
>
|
||||
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">{provider.label}</span>
|
||||
{isConfigured && (
|
||||
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto">
|
||||
<Check className="size-3 text-primary-foreground" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: no errors in `step-storage.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/steps/step-storage.tsx
|
||||
git commit -m "feat(onboarding): add two-phase config flow and org-combobox style to storage step"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Restyle `step-sso-gate.tsx`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/onboarding/steps/step-sso-gate.tsx`
|
||||
|
||||
**Context:** SSO provider buttons currently use shadcn `Button variant="outline"`. Replace with org-combobox card style using a `Globe` icon as placeholder (mockSsoConfig providers have no icon). "Continue with email" stays as a shadcn `Button`.
|
||||
|
||||
- [ ] **Step 1: Rewrite the file**
|
||||
|
||||
```typescript
|
||||
// src/features/onboarding/steps/step-sso-gate.tsx
|
||||
"use client";
|
||||
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { mockSsoConfig } from "@/features/onboarding/onboarding.mock";
|
||||
|
||||
export const StepSsoGate = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
|
||||
const chooseProvider = async (providerId: string) => {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, sso: { providerId } } });
|
||||
await next();
|
||||
};
|
||||
|
||||
const continueWithEmail = async () => {
|
||||
await next();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Welcome to Portabase</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Sign in with your organisation provider, or continue with email.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{mockSsoConfig.providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => chooseProvider(provider.id)}
|
||||
className="flex items-center gap-3 rounded-lg border border-border p-3 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full"
|
||||
>
|
||||
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span>Continue with {provider.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!mockSsoConfig.forced && (
|
||||
<Button type="button" onClick={continueWithEmail}>
|
||||
Continue with email
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: no errors in `step-sso-gate.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/steps/step-sso-gate.tsx
|
||||
git commit -m "style(onboarding): restyle sso-gate provider buttons to org-combobox card style"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Restyle `step-security.tsx`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/onboarding/steps/step-security.tsx`
|
||||
|
||||
**Context:** Single-button choice (passkey or two-factor). Replace `Button` with an org-combobox card. Add a relevant lucide icon (`KeyRound` for passkey, `ShieldCheck` for two-factor) inside the icon container.
|
||||
|
||||
- [ ] **Step 1: Rewrite the file**
|
||||
|
||||
```typescript
|
||||
// src/features/onboarding/steps/step-security.tsx
|
||||
"use client";
|
||||
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { KeyRound, ShieldCheck } from "lucide-react";
|
||||
import { mockSsoConfig } from "@/features/onboarding/onboarding.mock";
|
||||
|
||||
export const StepSecurity = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
|
||||
const choose = async (method: "passkey" | "two-factor") => {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, security: { method } } });
|
||||
await next();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Secure your account</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{mockSsoConfig.passkeyEnabled
|
||||
? "Set up a passkey for faster, safer sign-in."
|
||||
: "Set up two-factor authentication to protect your account."}
|
||||
</p>
|
||||
</div>
|
||||
{mockSsoConfig.passkeyEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose("passkey")}
|
||||
className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left"
|
||||
>
|
||||
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">Set up passkey</span>
|
||||
<span className="text-xs text-muted-foreground">Faster, safer sign-in</span>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose("two-factor")}
|
||||
className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left"
|
||||
>
|
||||
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">Set up two-factor</span>
|
||||
<span className="text-xs text-muted-foreground">Add an extra layer of security</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: no errors in `step-security.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/steps/step-security.tsx
|
||||
git commit -m "style(onboarding): restyle security method choice to org-combobox card style"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Restyle `step-preferences.tsx`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/onboarding/steps/step-preferences.tsx`
|
||||
|
||||
**Context:** Theme toggle currently uses shadcn `Button` with `variant="default"/"outline"` switching. Replace with org-combobox toggle cards: active state `bg-primary/10 text-primary border-primary/20`, inactive `border-border hover:bg-accent/50`, check badge on active. Sun/Moon icons in icon containers.
|
||||
|
||||
- [ ] **Step 1: Rewrite the file**
|
||||
|
||||
```typescript
|
||||
// src/features/onboarding/steps/step-preferences.tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { Check, Moon, Sun } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export const StepPreferences = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
||||
const [avatarDataUrl, setAvatarDataUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please select an image file.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_AVATAR_SIZE_BYTES) {
|
||||
toast.error("Image is too large. Please select a file under 2MB.");
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setAvatarDataUrl(reader.result as string);
|
||||
reader.onerror = () => toast.error("Failed to read the selected image. Please try again.");
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, preferences: { theme, avatarDataUrl } } });
|
||||
await next();
|
||||
};
|
||||
|
||||
const themeOptions: { value: "light" | "dark"; label: string; Icon: typeof Sun }[] = [
|
||||
{ value: "light", label: "Light", Icon: Sun },
|
||||
{ value: "dark", label: "Dark", Icon: Moon },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Make yourself at home</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Pick your theme and add a profile photo.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="size-12">
|
||||
<AvatarImage src={avatarDataUrl} alt="" />
|
||||
<AvatarFallback>?</AvatarFallback>
|
||||
</Avatar>
|
||||
<label className="text-sm underline cursor-pointer">
|
||||
Upload image
|
||||
<input type="file" accept="image/*" className="hidden" onChange={onFileChange} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{themeOptions.map(({ value, label, Icon }) => {
|
||||
const isActive = theme === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`flex items-center gap-2 rounded-lg border p-3 text-sm transition-colors flex-1 ${
|
||||
isActive
|
||||
? "border-primary/20 bg-primary/10 text-primary"
|
||||
: "border-border hover:bg-accent/50 hover:border-primary/20"
|
||||
}`}
|
||||
>
|
||||
<div className="size-7 rounded-md border bg-muted/50 flex items-center justify-center shrink-0">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
{isActive && (
|
||||
<div className="size-5 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="size-3 text-primary-foreground" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button type="button" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: no errors in `step-preferences.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/steps/step-preferences.tsx
|
||||
git commit -m "style(onboarding): restyle theme toggles in preferences step to org-combobox card style"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Restyle `step-project-create.tsx`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/onboarding/steps/step-project-create.tsx`
|
||||
|
||||
**Context:** DB toggle buttons have hardcoded `border-white/10` for inactive state. Replace with design-system tokens. Add icon container and check badge for active state (use `Database` icon from lucide).
|
||||
|
||||
- [ ] **Step 1: Rewrite the file**
|
||||
|
||||
```typescript
|
||||
// src/features/onboarding/steps/step-project-create.tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { Check, Database } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { mockDatabases } from "@/features/onboarding/onboarding.mock";
|
||||
|
||||
export const StepProjectCreate = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [databaseIds, setDatabaseIds] = useState<string[]>([]);
|
||||
|
||||
const toggleDb = (id: string) => {
|
||||
setDatabaseIds((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, project: { name, description, databaseIds } } });
|
||||
await next();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Create a project</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Optional — group databases under a project.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="project-name">Project name</Label>
|
||||
<Input id="project-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="My project" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="project-description">Description</Label>
|
||||
<Textarea id="project-description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Databases</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{mockDatabases.map((db) => {
|
||||
const isSelected = databaseIds.includes(db.id);
|
||||
return (
|
||||
<button
|
||||
key={db.id}
|
||||
type="button"
|
||||
onClick={() => toggleDb(db.id)}
|
||||
className={`flex items-center gap-3 rounded-lg border p-3 text-sm transition-colors text-left ${
|
||||
isSelected
|
||||
? "border-primary/20 bg-primary/10 text-primary"
|
||||
: "border-border hover:bg-accent/50 hover:border-primary/20"
|
||||
}`}
|
||||
>
|
||||
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
|
||||
<Database className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium">{db.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{db.engine}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto">
|
||||
<Check className="size-3 text-primary-foreground" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" onClick={onContinue} disabled={!name.trim()}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: no errors in `step-project-create.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/steps/step-project-create.tsx
|
||||
git commit -m "style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update `onboarding-steps.tsx` — conditional agent-create nextStep
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/onboarding/onboarding-steps.tsx`
|
||||
|
||||
**Context:** Change `agent-create.nextStep` from the static string `"agent-waiting"` to a function that checks `ctx.flowData.agents`. If agents were added → `"agent-waiting"` (normal flow). If none → `"finish"` (skips agent-waiting, project-create, db-settings). The `ctx` argument is the OnboardJS context object; its `flowData` property is `{ [key: string]: any }`.
|
||||
|
||||
- [ ] **Step 1: Update the `agent-create` entry in `onboarding-steps.tsx`**
|
||||
|
||||
Replace lines 82–87:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "agent-create",
|
||||
component: StepAgentCreate,
|
||||
isSkippable: true,
|
||||
skipToStep: undefined,
|
||||
nextStep: (ctx) => {
|
||||
const agents = ctx.flowData?.agents as unknown[] | undefined;
|
||||
return agents && agents.length > 0 ? "agent-waiting" : "finish";
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify type-check**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit`
|
||||
Expected: no errors in `onboarding-steps.tsx`. If TypeScript complains about the function signature for `nextStep`, check the `OnboardingStep` type in `node_modules/@onboardjs/react/dist/**/*.d.ts` and cast `ctx` as needed (e.g. `(ctx: any) => ...`).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/onboarding-steps.tsx
|
||||
git commit -m "feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Final verification
|
||||
|
||||
**Files:** None — read-only checks.
|
||||
|
||||
- [ ] **Step 1: Full type-check**
|
||||
|
||||
Run: `pnpm exec tsc --noEmit 2>&1 | grep "onboarding"`
|
||||
Expected: only the pre-existing `step-account-info.tsx:28` better-auth error. No new errors in any onboarding file.
|
||||
|
||||
- [ ] **Step 2: Verify step-invite-members and step-agent-create have no hardcoded colors**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "white/10\|zinc-\|#[0-9a-f]\{3,6\}" \
|
||||
src/features/onboarding/steps/step-invite-members.tsx \
|
||||
src/features/onboarding/steps/step-agent-create.tsx
|
||||
```
|
||||
Expected: no output. If any hardcoded color is found, replace with the design-system equivalent (`border-white/10` → `border-border`).
|
||||
|
||||
- [ ] **Step 3: Verify the step graph conditional skip**
|
||||
|
||||
Run: `grep -A8 '"agent-create"' src/features/onboarding/onboarding-steps.tsx`
|
||||
Expected: output shows `nextStep: (ctx) => {` function, not a plain string.
|
||||
|
||||
- [ ] **Step 4: Commit if Step 2 required fixes**
|
||||
|
||||
Only commit if `step-invite-members.tsx` or `step-agent-create.tsx` were modified:
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/steps/step-invite-members.tsx \
|
||||
src/features/onboarding/steps/step-agent-create.tsx
|
||||
git commit -m "style(onboarding): remove hardcoded colors from invite-members and agent-create"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,194 +0,0 @@
|
||||
# Onboarding Theme Persistence & Progress Bar Fix — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix two independent bugs in the onboarding flow — theme not persisted to DB on selection, and progress bar not reflecting the user's true position in the full 15-step flow.
|
||||
|
||||
**Architecture:** Two surgical edits in two separate files. Task 1 adds a single `authClient.updateUser({ theme })` call in `StepPreferences`. Task 2 replaces OnboardJS-relative progress values in `OnboardingStepper` with a manual calculation based on `STEP_ORDER`.
|
||||
|
||||
**Tech Stack:** Next.js (App Router), React, TypeScript, OnboardJS (`@onboardjs/react`), `next-themes`, `better-auth` (`authClient`)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- TypeScript — no `any` introduced, no type assertions added
|
||||
- No new dependencies
|
||||
- No API, schema, or DB migration changes
|
||||
- Follow existing import alias `@/` for all internal imports
|
||||
- Commits in English, conventional-commits format (`fix:`)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Persist theme to DB on selection in StepPreferences
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/onboarding/steps/step-preferences.tsx` (lines ~51-61)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `authClient.updateUser` from `@/lib/auth/auth-client` (already imported in the file)
|
||||
- Produces: nothing consumed by Task 2
|
||||
|
||||
- [ ] **Step 1: Locate the `selectTheme` function**
|
||||
|
||||
Open `src/features/onboarding/steps/step-preferences.tsx`. Find the `selectTheme` function (~line 51). It currently reads:
|
||||
|
||||
```ts
|
||||
const selectTheme = async (theme: ThemeKey) => {
|
||||
// Apply immediately to the UI
|
||||
setTheme(theme);
|
||||
//mettre à jour aussi en db!
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
preferences: { ...preferences, theme },
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `authClient.updateUser({ theme })` call**
|
||||
|
||||
Replace the `selectTheme` function with:
|
||||
|
||||
```ts
|
||||
const selectTheme = async (theme: ThemeKey) => {
|
||||
// Apply immediately to the UI
|
||||
setTheme(theme);
|
||||
// Persist to DB so it survives page reload
|
||||
await authClient.updateUser({ theme });
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
preferences: { ...preferences, theme },
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Note: `authClient` is already imported at the top of the file (`import { authClient } from "@/lib/auth/auth-client";`). No new import needed.
|
||||
|
||||
- [ ] **Step 3: Verify TypeScript compiles cleanly**
|
||||
|
||||
```bash
|
||||
cd /path/to/portabase && pnpm tsc --noEmit 2>&1 | grep step-preferences
|
||||
```
|
||||
|
||||
Expected: no output (no errors on that file).
|
||||
|
||||
- [ ] **Step 4: Manual smoke test**
|
||||
|
||||
1. Start the dev server: `pnpm dev`
|
||||
2. Open the onboarding flow in the browser
|
||||
3. Navigate to the **Preferences** step
|
||||
4. Click a theme (e.g. **Light**)
|
||||
5. Verify the UI switches immediately
|
||||
6. Open the Network tab → confirm a `PATCH` (or `POST`) request to the auth user update endpoint was made with `theme: "light"` in the payload
|
||||
7. Hard-reload the page → theme should persist
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/steps/step-preferences.tsx
|
||||
git commit -m "fix(onboarding): persist theme to db on selection in StepPreferences"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Fix progress bar to reflect full 15-step position
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/onboarding/onboarding-stepper.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `STEP_ORDER` from `@/features/onboarding/constants/steps` (already exported, already used in `onboarding-shell.tsx`)
|
||||
- Produces: nothing consumed by Task 1
|
||||
|
||||
- [ ] **Step 1: Open the current stepper**
|
||||
|
||||
Open `src/features/onboarding/onboarding-stepper.tsx`. It currently reads:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
export const OnboardingStepper = () => {
|
||||
const { state } = useOnboarding();
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Step {state.currentStepNumber} of {state.totalSteps}
|
||||
</span>
|
||||
<span>{Math.round(state.progressPercentage)}%</span>
|
||||
</div>
|
||||
<Progress value={state.progressPercentage} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace with STEP_ORDER-based calculation**
|
||||
|
||||
Replace the entire file content with:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { STEP_ORDER } from "@/features/onboarding/constants/steps";
|
||||
|
||||
export const OnboardingStepper = () => {
|
||||
const { state } = useOnboarding();
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
const currentId = String(state.currentStep?.id ?? "");
|
||||
const currentIndex = Math.max(0, STEP_ORDER.indexOf(currentId));
|
||||
const totalSteps = STEP_ORDER.length;
|
||||
const stepNumber = currentIndex + 1;
|
||||
const progress = Math.round((currentIndex / (totalSteps - 1)) * 100);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Step {stepNumber} of {totalSteps}
|
||||
</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<Progress value={progress} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Why `Math.max(0, ...)`:** If `currentId` is not in `STEP_ORDER` (unknown step), `indexOf` returns `-1`. Clamping to `0` gives a safe fallback of "Step 1 of 15 — 0%" rather than a negative/NaN value.
|
||||
|
||||
**Why `totalSteps - 1` in the divisor:** At `login` (index 0) → 0%. At `finish` (index 14) → 14/14 = 100%. Without the `-1` the last step would be 93%.
|
||||
|
||||
- [ ] **Step 3: Verify TypeScript compiles cleanly**
|
||||
|
||||
```bash
|
||||
pnpm tsc --noEmit 2>&1 | grep onboarding-stepper
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 4: Manual smoke test**
|
||||
|
||||
1. Simulate resuming onboarding mid-flow (e.g. log out, log back in as a user who already has an org — should resume at `invite-members`, index 5)
|
||||
2. Verify the stepper shows **"Step 6 of 15 — 36%"** and the progress bar is ~1/3 filled
|
||||
3. Click through a few steps and verify the number and percentage increase correctly each time
|
||||
4. Reach the `finish` step and verify **"Step 15 of 15 — 100%"**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/onboarding/onboarding-stepper.tsx
|
||||
git commit -m "fix(onboarding): recalculate progress bar from full STEP_ORDER position"
|
||||
```
|
||||
@@ -1,172 +0,0 @@
|
||||
# Onboarding Channel Config + Style Refactor
|
||||
|
||||
**Date:** 2026-06-17
|
||||
**Branch:** poc/onboarding-setup
|
||||
**Scope:** Two concerns:
|
||||
1. Notifier/storage steps gain a real two-phase configuration flow (fake data, stored in flowData)
|
||||
2. All onboarding step cards, toggles, and inputs adopt the org-combobox design language
|
||||
|
||||
---
|
||||
|
||||
## 1. Configuration Flow — Notifier & Storage Steps
|
||||
|
||||
### Goal
|
||||
|
||||
When a user clicks a provider in the notifier or storage step, they should be able to configure it (Slack webhook URL, S3 credentials, etc.) before it is added to their list. Data is stored in `flowData` only — no server actions are called during onboarding.
|
||||
|
||||
### Phase Machine
|
||||
|
||||
Each step manages a local phase:
|
||||
|
||||
```ts
|
||||
type Phase =
|
||||
| { kind: "grid" }
|
||||
| { kind: "configuring"; provider: string }
|
||||
```
|
||||
|
||||
**Phase "grid"**
|
||||
- Shows the provider selection grid (style: see Section 2)
|
||||
- Already-configured providers show at top with check badge and remove button
|
||||
- Unconfigured providers are clickable → transition to `configuring`
|
||||
- "Continue" button visible if at least 0 channels configured (step is optional)
|
||||
- "Add another" link/button visible when at least 1 configured
|
||||
|
||||
**Phase "configuring"**
|
||||
- Header: provider icon + name + "Back" button (returns to grid, no save)
|
||||
- Channel name `Input` (required, min 5 chars per existing schema)
|
||||
- `renderChannelForm(provider, form)` renders the provider-specific fields
|
||||
- `useZodForm` with `NotificationChannelFormSchema` (notifier) or `StorageChannelFormSchema` (storage)
|
||||
- Submit validates → appends `{ id: crypto.randomUUID(), provider, label, name, config }` to local channels list → returns to grid phase
|
||||
- No server action called
|
||||
|
||||
### Updated `OnboardingChannel` type
|
||||
|
||||
```ts
|
||||
export type OnboardingChannel = {
|
||||
id: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
```
|
||||
|
||||
`step-defaults.tsx` reads `id` and `label` from notifiers/storages — unchanged, continues to work.
|
||||
|
||||
### Files
|
||||
|
||||
- `src/features/onboarding/onboarding.types.ts` — extend `OnboardingChannel`
|
||||
- `src/features/onboarding/steps/step-notifier.tsx` — full rewrite
|
||||
- `src/features/onboarding/steps/step-storage.tsx` — full rewrite
|
||||
|
||||
### Dependencies reused
|
||||
|
||||
- `renderChannelForm` from `@/features/channel/channels-helpers`
|
||||
- `NotificationChannelFormSchema` / `StorageChannelFormSchema` from `@/features/channel/channel-form.schema`
|
||||
- `useZodForm`, `Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormMessage` from `@/components/ui/form`
|
||||
- `notificationProviders` / `storageProviders` from their respective helpers
|
||||
|
||||
---
|
||||
|
||||
## 2. Style Refactor — All Onboarding Steps
|
||||
|
||||
### Goal
|
||||
|
||||
Replace hardcoded dark colors and inconsistent `<button>` styling across all step components with design-system tokens matching the org-combobox visual language.
|
||||
|
||||
### Design Tokens
|
||||
|
||||
**Toggle/selection cards** (provider cards, theme buttons, DB toggles, SSO buttons, security method buttons):
|
||||
|
||||
| State | Classes |
|
||||
|----------|---------|
|
||||
| Inactive | `border border-border rounded-lg hover:bg-accent/50 hover:border-primary/20 transition-colors` |
|
||||
| Active | `border border-primary/20 bg-primary/10 text-primary rounded-lg` |
|
||||
|
||||
**Icon container** (inside provider cards):
|
||||
```
|
||||
size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center
|
||||
```
|
||||
|
||||
**Check badge** (selected state on provider cards):
|
||||
```
|
||||
size-5 rounded-full bg-primary flex items-center justify-center ml-auto
|
||||
→ Check className="size-3 text-primary-foreground" strokeWidth={3}
|
||||
```
|
||||
|
||||
**Inputs** (`Input`, `Textarea`): already shadcn — remove any hardcoded color overrides. No component change.
|
||||
|
||||
**Navigation buttons** (`Button` shadcn variants `default`/`outline`/`ghost`): unchanged, already correct.
|
||||
|
||||
**Shell** (`onboarding-shell.tsx`): `bg-zinc-950`/`bg-zinc-900` intentionally kept dark — not changed.
|
||||
|
||||
### Files Affected
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `step-sso-gate.tsx` | SSO provider buttons → toggle card style |
|
||||
| `step-security.tsx` | Passkey/2FA method buttons → toggle card style |
|
||||
| `step-preferences.tsx` | Theme toggle buttons → toggle card style |
|
||||
| `step-project-create.tsx` | DB toggle buttons → toggle card style |
|
||||
| `step-notifier.tsx` | Provider cards → toggle card style (done as part of config flow rewrite) |
|
||||
| `step-storage.tsx` | Provider cards → toggle card style (done as part of config flow rewrite) |
|
||||
| `step-invite-members.tsx` | Audit for hardcoded colors, clean if needed |
|
||||
| `step-agent-create.tsx` | Audit for hardcoded colors, clean if needed |
|
||||
|
||||
---
|
||||
|
||||
## 3. Conditional Step Graph — Skip Agent Steps When No Agents
|
||||
|
||||
### Rule
|
||||
|
||||
If the user skips or submits `agent-create` with zero agents (`flowData.agents` empty or undefined), skip `agent-waiting`, `project-create`, and `db-settings` — jump directly to `finish`.
|
||||
|
||||
### Step Graph Change
|
||||
|
||||
`onboarding-steps.tsx` — `agent-create` entry:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: "agent-create",
|
||||
component: StepAgentCreate,
|
||||
isSkippable: true,
|
||||
nextStep: (ctx) => {
|
||||
const agents = ctx.flowData?.agents as unknown[] | undefined;
|
||||
return agents && agents.length > 0 ? "agent-waiting" : "finish";
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
All other steps unchanged.
|
||||
|
||||
### Rationale
|
||||
|
||||
- `agent-waiting` pings the agent — no agent to ping
|
||||
- `project-create` groups databases under an agent project — no agent, no project
|
||||
- `db-settings` configures agent DB retention — no project selected, empty state anyway
|
||||
|
||||
### Files
|
||||
|
||||
- `src/features/onboarding/onboarding-steps.tsx` — update `agent-create.nextStep`
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Server actions / real DB persistence during onboarding (fake data only)
|
||||
- `step-account-info.tsx`, `step-org-create.tsx`, `step-defaults.tsx`, `step-agent-waiting.tsx`, `step-finish.tsx` — no toggle cards, no hardcoded colors
|
||||
- `onboarding-shell.tsx` background colors — intentionally dark
|
||||
- Any changes outside `src/features/onboarding/`
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Clicking a provider in notifier/storage step → shows config form with correct provider-specific fields
|
||||
2. Submit config form → validates with existing schema → provider appears in grid with check badge and remove button
|
||||
3. Continue → saves all configured channels (with `name` + `config`) to `flowData.notifiers` / `flowData.storages`
|
||||
4. All toggle cards across steps use design-system tokens (no `border-white/10`, no hardcoded bg)
|
||||
5. `step-defaults.tsx` selects still work (reads `id` + `label` from channels)
|
||||
6. `pnpm exec tsc --noEmit` — no new errors in onboarding files
|
||||
7. Skip agent-create with 0 agents → jumps to finish (agent-waiting, project-create, db-settings not shown)
|
||||
8. Create agents → normal flow through agent-waiting → project-create → db-settings → finish
|
||||
@@ -1,111 +0,0 @@
|
||||
# Onboarding Feature Refactor — Design Spec
|
||||
_2026-06-19_
|
||||
|
||||
## Goal
|
||||
|
||||
Reorganise `src/features/onboarding/` into clean sub-directories. Replace ad-hoc `useState(loading)` + try/finally patterns with `useMutation`/`useQuery` hooks. Steps become pure JSX shells.
|
||||
|
||||
## Directory Structure (target)
|
||||
|
||||
```
|
||||
onboarding/
|
||||
actions/ ← server actions (unchanged)
|
||||
constants/
|
||||
steps.ts ← STEP_ORDER array + STEP_IDS const
|
||||
hooks/
|
||||
use-create-agent.ts
|
||||
use-delete-agent.ts
|
||||
use-agent-status.ts ← useQuery, 3s poll
|
||||
use-create-org.ts
|
||||
use-create-project.ts
|
||||
use-update-account.ts
|
||||
use-add-notifier.ts
|
||||
use-remove-notifier.ts
|
||||
use-add-storage.ts
|
||||
use-remove-storage.ts
|
||||
use-generate-edge-key.ts
|
||||
use-mark-onboarding-done.ts
|
||||
schemas/
|
||||
account.schema.ts ← BaseSchema + WithPasswordSchema
|
||||
types/
|
||||
index.ts ← content of onboarding.types.ts (moved)
|
||||
utils/ ← reserved, empty for now
|
||||
steps/ ← existing components, thinned to JSX
|
||||
onboarding-checklist.tsx
|
||||
onboarding-shell.tsx
|
||||
onboarding-state.ts
|
||||
onboarding-stepper.tsx
|
||||
onboarding-steps.tsx
|
||||
is-onboarding-done.ts
|
||||
onboarding.types.ts ← deleted once types/index.ts takes over
|
||||
```
|
||||
|
||||
## Hook Contract
|
||||
|
||||
Every hook calls `useOnboarding()` internally. Steps pass nothing — they only import the hook.
|
||||
|
||||
**Mutation hooks** return `UseMutationResult` directly so steps get `mutate`, `isPending`, `isError` etc. from TanStack Query natively.
|
||||
|
||||
```ts
|
||||
// Pattern
|
||||
export const useCreateAgent = () => {
|
||||
const { state, updateContext } = useOnboarding();
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const orgId = (state?.context.flowData.org as any)?.id;
|
||||
if (!orgId) throw new Error("Missing org ID");
|
||||
const result = await createAgentAction({ organizationId: orgId, data: { name, description: "" } });
|
||||
if (!result?.data?.data) throw new Error(result?.serverError ?? "Failed to create agent");
|
||||
const newAgent: OnboardingAgent = { id: result.data.data.id, name: result.data.data.name };
|
||||
const agents = [...((state?.context.flowData.agents ?? []) as OnboardingAgent[]), newAgent];
|
||||
await updateContext({ flowData: { ...state?.context.flowData, agents } });
|
||||
return newAgent;
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**Query hooks** return `UseQueryResult`:
|
||||
|
||||
```ts
|
||||
export const useAgentStatus = () => {
|
||||
const { state } = useOnboarding();
|
||||
const agentId = (state?.context.flowData.agents as OnboardingAgent[])?.[0]?.id;
|
||||
return useQuery({
|
||||
queryKey: ["onboarding-agent-status", agentId],
|
||||
queryFn: async () => { /* getAgentStatusAction */ },
|
||||
refetchInterval: 3_000,
|
||||
enabled: !!agentId,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Constants
|
||||
|
||||
`constants/steps.ts` exports:
|
||||
- `STEP_ORDER: string[]` — the full ordered list used by the shell for Back/Next logic
|
||||
- `STEP_IDS` — typed const object `{ LOGIN: "login", ... }` for safe step references
|
||||
|
||||
## Schemas
|
||||
|
||||
`schemas/account.schema.ts` exports `BaseSchema` and `WithPasswordSchema` (currently inline in `step-account-info.tsx`).
|
||||
|
||||
## Types
|
||||
|
||||
`types/index.ts` is a verbatim move of `onboarding.types.ts`. All existing imports updated from `@/features/onboarding/onboarding.types` → `@/features/onboarding/types`.
|
||||
|
||||
## Steps After Refactor
|
||||
|
||||
Each step file shrinks to: imports + JSX + hook call. No direct action imports, no manual loading state.
|
||||
|
||||
Example diff for `step-agent-create.tsx`:
|
||||
- Remove: `createAgentAction`, `deleteAgentAction`, `useState(adding)`, manual try/finally
|
||||
- Add: `useCreateAgent()`, `useDeleteAgent()`
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `onboarding-state.ts` server logic — untouched
|
||||
- `onboarding-steps.tsx` step registry — untouched
|
||||
- UI/UX changes to any step
|
||||
- Adding new steps
|
||||
@@ -1,98 +0,0 @@
|
||||
# Onboarding — Theme Persistence & Progress Bar Fix
|
||||
|
||||
**Date:** 2026-06-19
|
||||
**Scope:** `src/features/onboarding`
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Two independent bugs in the onboarding flow:
|
||||
|
||||
1. **Theme not persisted to DB** — selecting a theme in `StepPreferences` applies it visually via `next-themes` but never calls `authClient.updateUser({ theme })`, so the preference is lost on page reload unless the user also saves an avatar.
|
||||
|
||||
2. **Progress bar starts from current step, not from the beginning** — `OnboardingStepper` uses `state.progressPercentage`, `state.currentStepNumber`, and `state.totalSteps` from OnboardJS. When onboarding resumes mid-flow (e.g. at step `invite-members`), OnboardJS recalculates these values relative to the remaining steps in the current session, not relative to the full 15-step flow. This makes the bar start at 0% even though the user is already 40%+ through.
|
||||
|
||||
---
|
||||
|
||||
## Fix 1 — Theme Persistence
|
||||
|
||||
**File:** `src/features/onboarding/steps/step-preferences.tsx`
|
||||
|
||||
### Current behaviour
|
||||
|
||||
```ts
|
||||
const selectTheme = async (theme: ThemeKey) => {
|
||||
setTheme(theme); // ✅ updates next-themes (UI)
|
||||
// mettre à jour aussi en db! ← comment acknowledging the bug
|
||||
await updateContext({ ... });
|
||||
};
|
||||
```
|
||||
|
||||
### Target behaviour
|
||||
|
||||
```ts
|
||||
const selectTheme = async (theme: ThemeKey) => {
|
||||
setTheme(theme);
|
||||
await authClient.updateUser({ theme }); // ← add this line
|
||||
await updateContext({ ... });
|
||||
};
|
||||
```
|
||||
|
||||
**Reference:** `src/features/profile/profile-appearance.tsx` already does this correctly.
|
||||
|
||||
**Error handling:** No change — if `updateUser` fails, the UI theme is still applied and the user can continue. No toast/error needed at this stage.
|
||||
|
||||
---
|
||||
|
||||
## Fix 2 — Progress Bar Recalculation
|
||||
|
||||
**File:** `src/features/onboarding/onboarding-stepper.tsx`
|
||||
|
||||
### Root cause
|
||||
|
||||
OnboardJS computes `progressPercentage` and `currentStepNumber` relative to the steps it was initialised with in the current session. When resuming mid-flow, the step list seen by OnboardJS starts at the resume step, not at `login`.
|
||||
|
||||
### Solution
|
||||
|
||||
Replace all OnboardJS progress values with a manual calculation based on `STEP_ORDER` (the canonical ordered list of all 15 steps, already defined in `src/features/onboarding/constants/steps.ts`).
|
||||
|
||||
```ts
|
||||
import { STEP_ORDER } from "@/features/onboarding/constants/steps";
|
||||
|
||||
const currentId = String(state?.currentStep?.id ?? "");
|
||||
const currentIndex = Math.max(0, STEP_ORDER.indexOf(currentId)); // guard: -1 → 0
|
||||
const totalSteps = STEP_ORDER.length; // 15
|
||||
const stepNumber = currentIndex + 1; // 1-based
|
||||
const progress = Math.round((currentIndex / (totalSteps - 1)) * 100);
|
||||
```
|
||||
|
||||
| Step | Index | stepNumber | progress |
|
||||
|------------------|-------|------------|----------|
|
||||
| login | 0 | 1 of 15 | 0% |
|
||||
| preferences | 3 | 4 of 15 | 21% |
|
||||
| invite-members | 5 | 6 of 15 | 36% |
|
||||
| storage | 7 | 8 of 15 | 50% |
|
||||
| finish | 14 | 15 of 15 | 100% |
|
||||
|
||||
**Edge case:** If `currentId` is not found in `STEP_ORDER`, `indexOf` returns `-1`, clamped to `0` by `Math.max` → shows step 1 of 15 at 0%. Safe degradation.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/features/onboarding/steps/step-preferences.tsx` | Add `authClient.updateUser({ theme })` in `selectTheme` |
|
||||
| `src/features/onboarding/onboarding-stepper.tsx` | Replace OnboardJS progress values with `STEP_ORDER`-based calculation |
|
||||
|
||||
No new files, no schema changes, no API changes.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Adding a toast/error on failed theme save
|
||||
- Persisting the avatar selection to DB during selection (only on "Continue" — existing behaviour unchanged)
|
||||
- Refactoring OnboardJS step initialisation
|
||||
Reference in New Issue
Block a user