@arch-cadre/setup 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/actions.ts +173 -0
- package/src/index.ts +19 -0
- package/src/intl.d.ts +13 -0
- package/src/routes.ts +10 -0
- package/src/ui/pages/setup-wizard.tsx +261 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arch-cadre/setup",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Installation and setup module for Kryo",
|
|
6
6
|
"exports": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
|
+
"src",
|
|
16
17
|
"locales",
|
|
17
18
|
"manifest.json"
|
|
18
19
|
],
|
|
@@ -26,13 +27,13 @@
|
|
|
26
27
|
"lint": "biome check --write"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@arch-cadre/modules": "^0.0.
|
|
30
|
-
"@arch-cadre/ui": "^0.0.
|
|
30
|
+
"@arch-cadre/modules": "^0.0.79",
|
|
31
|
+
"@arch-cadre/ui": "^0.0.53",
|
|
31
32
|
"drizzle-orm": "1.0.0-beta.6-4414a19",
|
|
32
33
|
"lucide-react": "^0.475.0"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
|
-
"@arch-cadre/core": "^0.0.
|
|
36
|
+
"@arch-cadre/core": "^0.0.53",
|
|
36
37
|
"@types/node": "^20.19.9",
|
|
37
38
|
"@types/react": "^19",
|
|
38
39
|
"@types/react-dom": "^19",
|
|
@@ -41,8 +42,8 @@
|
|
|
41
42
|
"unbuild": "^3.6.1"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
|
-
"@arch-cadre/core": "^0.0.
|
|
45
|
-
"@arch-cadre/intl": "^0.0.
|
|
45
|
+
"@arch-cadre/core": "^0.0.53",
|
|
46
|
+
"@arch-cadre/intl": "^0.0.53",
|
|
46
47
|
"next": ">=13.0.0",
|
|
47
48
|
"react": "^19.0.0",
|
|
48
49
|
"react-dom": "^19.0.0"
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import {
|
|
7
|
+
createUser,
|
|
8
|
+
db,
|
|
9
|
+
permissionsTable,
|
|
10
|
+
rolesTable,
|
|
11
|
+
rolesToPermissionsTable,
|
|
12
|
+
systemModulesTable,
|
|
13
|
+
usersToRolesTable,
|
|
14
|
+
userTable,
|
|
15
|
+
} from "@arch-cadre/core/server";
|
|
16
|
+
import { getKryoPathPrefix, getModules } from "@arch-cadre/modules/server";
|
|
17
|
+
import { eq, inArray, sql } from "drizzle-orm";
|
|
18
|
+
import { redirect } from "next/navigation";
|
|
19
|
+
|
|
20
|
+
const execAsync = promisify(exec);
|
|
21
|
+
|
|
22
|
+
export async function syncDatabase() {
|
|
23
|
+
try {
|
|
24
|
+
// console.log("[Setup:Actions] Starting database synchronization...");
|
|
25
|
+
const root = process.cwd();
|
|
26
|
+
|
|
27
|
+
// Force non-interactive push.
|
|
28
|
+
// We use 'npx drizzle-kit push' to ensure it's available.
|
|
29
|
+
// We explicitly point to the config file in the app directory.
|
|
30
|
+
const configPath = path.join(root, "drizzle.config.ts");
|
|
31
|
+
|
|
32
|
+
console.log(`[Setup:Actions] Using config: ${configPath}`);
|
|
33
|
+
|
|
34
|
+
// Drizzle-kit push --force ensures it doesn't wait for user input
|
|
35
|
+
const { stdout, stderr } = await execAsync(
|
|
36
|
+
`npx drizzle-kit push --config=${configPath}`,
|
|
37
|
+
{
|
|
38
|
+
cwd: root,
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
// Ensure we are in non-interactive mode if the tool supports it
|
|
42
|
+
CI: "true",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
console.log("[Setup:Actions] Sync stdout:", stdout);
|
|
48
|
+
if (stderr) console.warn("[Setup:Actions] Sync stderr:", stderr);
|
|
49
|
+
|
|
50
|
+
return { success: true };
|
|
51
|
+
} catch (e: any) {
|
|
52
|
+
console.error("[Setup:Actions] Sync failed:", e);
|
|
53
|
+
// Even if it failed, sometimes it's because of minor warnings.
|
|
54
|
+
// But usually, it's a real error.
|
|
55
|
+
return { success: false, error: e.message };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function getAvailableModules() {
|
|
60
|
+
const modules = await getModules();
|
|
61
|
+
return modules.filter((m) => m.id !== "setup");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function finishSetup(
|
|
65
|
+
formData: FormData,
|
|
66
|
+
selectedModuleIds: string[],
|
|
67
|
+
) {
|
|
68
|
+
const email = formData.get("email") as string;
|
|
69
|
+
const username = formData.get("username") as string;
|
|
70
|
+
const password = formData.get("password") as string;
|
|
71
|
+
|
|
72
|
+
if (!email || !username || !password) {
|
|
73
|
+
throw new Error("Missing required fields");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 1. Create the user
|
|
77
|
+
const user = await createUser(email, username, password);
|
|
78
|
+
|
|
79
|
+
// 2. Ensure 'admin' role exists and assign it
|
|
80
|
+
await db.transaction(async (tx) => {
|
|
81
|
+
// Must have email verified admin user
|
|
82
|
+
await tx
|
|
83
|
+
.update(userTable)
|
|
84
|
+
.set({
|
|
85
|
+
emailVerifiedAt: new Date(),
|
|
86
|
+
})
|
|
87
|
+
.where(eq(userTable.id, user.id));
|
|
88
|
+
|
|
89
|
+
let [adminRole] = await tx
|
|
90
|
+
.select()
|
|
91
|
+
.from(rolesTable)
|
|
92
|
+
.where(eq(rolesTable.name, "admin"));
|
|
93
|
+
|
|
94
|
+
if (!adminRole) {
|
|
95
|
+
const [newRole] = await tx
|
|
96
|
+
.insert(rolesTable)
|
|
97
|
+
.values({
|
|
98
|
+
name: "admin",
|
|
99
|
+
description: "System Administrator with full access",
|
|
100
|
+
})
|
|
101
|
+
.returning();
|
|
102
|
+
adminRole = newRole;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Ensure system permissions exist
|
|
106
|
+
const systemPermissions = [
|
|
107
|
+
{ name: "system:modules", description: "Manage system modules" },
|
|
108
|
+
{ name: "system:rbac", description: "Manage roles and permissions" },
|
|
109
|
+
{
|
|
110
|
+
name: "system:activity-logs",
|
|
111
|
+
description: "View system activity logs",
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
console.log("Ensuring system permissions exist...");
|
|
116
|
+
for (const perm of systemPermissions) {
|
|
117
|
+
const [existing] = await tx
|
|
118
|
+
.select()
|
|
119
|
+
.from(permissionsTable)
|
|
120
|
+
.where(eq(permissionsTable.name, perm.name));
|
|
121
|
+
|
|
122
|
+
let permId = existing?.id;
|
|
123
|
+
if (!existing) {
|
|
124
|
+
const [inserted] = await tx
|
|
125
|
+
.insert(permissionsTable)
|
|
126
|
+
.values(perm)
|
|
127
|
+
.returning();
|
|
128
|
+
permId = inserted.id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Assign permission to admin role
|
|
132
|
+
await tx
|
|
133
|
+
.insert(rolesToPermissionsTable)
|
|
134
|
+
.values({
|
|
135
|
+
roleId: adminRole.id,
|
|
136
|
+
permissionId: permId,
|
|
137
|
+
})
|
|
138
|
+
.onConflictDoNothing();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await tx.insert(usersToRolesTable).values({
|
|
142
|
+
userId: user.id,
|
|
143
|
+
roleId: adminRole.id,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 3. Enable selected modules
|
|
147
|
+
if (selectedModuleIds.length > 0) {
|
|
148
|
+
for (const modId of selectedModuleIds) {
|
|
149
|
+
await tx.insert(systemModulesTable).values({
|
|
150
|
+
id: modId,
|
|
151
|
+
enabled: true,
|
|
152
|
+
system: true,
|
|
153
|
+
installed: true,
|
|
154
|
+
});
|
|
155
|
+
// .onConflictUpdate({
|
|
156
|
+
// target: systemModulesTable.id,
|
|
157
|
+
// set: { enabled: true },
|
|
158
|
+
// });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
redirect(await getKryoPathPrefix());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function checkDbConnection() {
|
|
167
|
+
try {
|
|
168
|
+
await db.execute(sql`SELECT 1`);
|
|
169
|
+
return { success: true };
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { success: false, error: String(e) };
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IModule } from "@arch-cadre/modules";
|
|
2
|
+
import manifest from "../manifest.json";
|
|
3
|
+
import { publicRoutes } from "./routes";
|
|
4
|
+
|
|
5
|
+
export * from "./actions";
|
|
6
|
+
|
|
7
|
+
const setupModule: IModule = {
|
|
8
|
+
manifest: manifest as any,
|
|
9
|
+
|
|
10
|
+
init: async () => {
|
|
11
|
+
console.log("[SetupModule] initialized.");
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
routes: {
|
|
15
|
+
public: publicRoutes,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default setupModule;
|
package/src/intl.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type messages from "../locales/en/global.json";
|
|
2
|
+
|
|
3
|
+
type JsonDataType = typeof messages;
|
|
4
|
+
|
|
5
|
+
// declare global {
|
|
6
|
+
// interface IntlMessages extends JsonDataType {}
|
|
7
|
+
// }
|
|
8
|
+
|
|
9
|
+
declare module "@arch-cadre/intl" {
|
|
10
|
+
export interface IntlMessages extends JsonDataType {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export {};
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/** biome-ignore-all lint/a11y/noLabelWithoutControl: <all> */
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { useTranslation } from "@arch-cadre/intl";
|
|
5
|
+
import {
|
|
6
|
+
Badge,
|
|
7
|
+
Button,
|
|
8
|
+
Card,
|
|
9
|
+
CardContent,
|
|
10
|
+
CardDescription,
|
|
11
|
+
CardFooter,
|
|
12
|
+
CardHeader,
|
|
13
|
+
CardTitle,
|
|
14
|
+
Checkbox,
|
|
15
|
+
Input,
|
|
16
|
+
Label,
|
|
17
|
+
} from "@arch-cadre/ui";
|
|
18
|
+
import { Logo } from "@arch-cadre/ui/brand/logo";
|
|
19
|
+
import { Loader } from "@arch-cadre/ui/shared/loader";
|
|
20
|
+
import {
|
|
21
|
+
CheckCircle2,
|
|
22
|
+
Database,
|
|
23
|
+
LayoutGrid,
|
|
24
|
+
Loader2,
|
|
25
|
+
Rocket,
|
|
26
|
+
UserPlus,
|
|
27
|
+
} from "lucide-react";
|
|
28
|
+
import * as React from "react";
|
|
29
|
+
import { finishSetup, getAvailableModules, syncDatabase } from "../../actions";
|
|
30
|
+
|
|
31
|
+
export function SetupWizard() {
|
|
32
|
+
const { t } = useTranslation();
|
|
33
|
+
const [step, setStep] = React.useState(1);
|
|
34
|
+
const [loading, setLoading] = React.useState(false);
|
|
35
|
+
const [syncStatus, setSyncStatus] = React.useState<
|
|
36
|
+
"idle" | "syncing" | "success" | "error"
|
|
37
|
+
>("idle");
|
|
38
|
+
const [availableModules, setAvailableModules] = React.useState<any[]>([]);
|
|
39
|
+
const [selectedModules, setSelectedModules] = React.useState<string[]>([]);
|
|
40
|
+
|
|
41
|
+
const nextStep = () => setStep((s) => s + 1);
|
|
42
|
+
|
|
43
|
+
const handleSync = async () => {
|
|
44
|
+
setSyncStatus("syncing");
|
|
45
|
+
const result = await syncDatabase();
|
|
46
|
+
if (result.success) {
|
|
47
|
+
setSyncStatus("success");
|
|
48
|
+
// Fetch modules after successful sync
|
|
49
|
+
const modules = await getAvailableModules();
|
|
50
|
+
|
|
51
|
+
setAvailableModules(modules.filter((m) => !m.system));
|
|
52
|
+
// Pre-select system modules
|
|
53
|
+
setSelectedModules(modules.filter((m) => m.system).map((m) => m.id));
|
|
54
|
+
setTimeout(nextStep, 1500);
|
|
55
|
+
} else {
|
|
56
|
+
setSyncStatus("error");
|
|
57
|
+
alert(`Database sync failed: ${result.error}`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const toggleModule = (id: string) => {
|
|
62
|
+
setSelectedModules((prev) =>
|
|
63
|
+
prev.includes(id) ? prev.filter((m) => m !== id) : [...prev, id],
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="min-h-screen flex items-center justify-center bg-muted/30 p-4">
|
|
69
|
+
<div className="w-full max-w-md space-y-8">
|
|
70
|
+
<div className="flex flex-col gap-4 items-center justify-center text-center">
|
|
71
|
+
{/*<h1 className="text-4xl font-bold tracking-tight text-primary">
|
|
72
|
+
Kryo
|
|
73
|
+
</h1>*/}
|
|
74
|
+
|
|
75
|
+
<Logo className="mx-auto" />
|
|
76
|
+
|
|
77
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
78
|
+
{t("setup_description")}
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<Card className="border-2 shadow-xl">
|
|
83
|
+
{step === 1 && (
|
|
84
|
+
<>
|
|
85
|
+
<CardHeader>
|
|
86
|
+
<CardTitle className="flex items-center gap-2">
|
|
87
|
+
<Rocket className="h-5 w-5 text-primary" />
|
|
88
|
+
{t("welcome")}
|
|
89
|
+
</CardTitle>
|
|
90
|
+
<CardDescription>{t("thank_you")}</CardDescription>
|
|
91
|
+
</CardHeader>
|
|
92
|
+
<CardContent>
|
|
93
|
+
<div className="space-y-4">
|
|
94
|
+
<p className="text-sm">{t("step_1_desc")}</p>
|
|
95
|
+
</div>
|
|
96
|
+
</CardContent>
|
|
97
|
+
<CardFooter>
|
|
98
|
+
<Button onClick={nextStep} className="w-full">
|
|
99
|
+
{t("get_started")}
|
|
100
|
+
</Button>
|
|
101
|
+
</CardFooter>
|
|
102
|
+
</>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
{step === 2 && (
|
|
106
|
+
<>
|
|
107
|
+
<CardHeader>
|
|
108
|
+
<CardTitle className="flex items-center gap-2">
|
|
109
|
+
<Database className="h-5 w-5 text-primary" />
|
|
110
|
+
{t("db_prep")}
|
|
111
|
+
</CardTitle>
|
|
112
|
+
<CardDescription>{t("db_desc")}</CardDescription>
|
|
113
|
+
</CardHeader>
|
|
114
|
+
<CardContent className="flex flex-col items-center justify-center py-6">
|
|
115
|
+
{syncStatus === "idle" && (
|
|
116
|
+
<Database className="h-12 w-12 text-muted-foreground mb-4" />
|
|
117
|
+
)}
|
|
118
|
+
{syncStatus === "syncing" && (
|
|
119
|
+
<Loader className="h-12 w-12 text-primary animate-spin mb-4" />
|
|
120
|
+
)}
|
|
121
|
+
{syncStatus === "success" && (
|
|
122
|
+
<CheckCircle2 className="h-12 w-12 text-green-500 mb-4" />
|
|
123
|
+
)}
|
|
124
|
+
<p className="text-center text-sm font-medium">
|
|
125
|
+
{syncStatus === "idle" && t("db_ready_to_sync")}
|
|
126
|
+
{syncStatus === "syncing" && t("db_creating_tables")}
|
|
127
|
+
{syncStatus === "success" && t("db_ready")}
|
|
128
|
+
{syncStatus === "error" && t("db_sync_failed")}
|
|
129
|
+
</p>
|
|
130
|
+
</CardContent>
|
|
131
|
+
<CardFooter>
|
|
132
|
+
<Button
|
|
133
|
+
onClick={handleSync}
|
|
134
|
+
className="w-full"
|
|
135
|
+
disabled={
|
|
136
|
+
syncStatus === "syncing" || syncStatus === "success"
|
|
137
|
+
}
|
|
138
|
+
>
|
|
139
|
+
{syncStatus === "syncing"
|
|
140
|
+
? t("db_syncing_btn")
|
|
141
|
+
: t("db_init_btn")}
|
|
142
|
+
</Button>
|
|
143
|
+
</CardFooter>
|
|
144
|
+
</>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{step === 3 && (
|
|
148
|
+
<>
|
|
149
|
+
<CardHeader>
|
|
150
|
+
<CardTitle className="flex items-center gap-2">
|
|
151
|
+
<LayoutGrid className="h-5 w-5 text-primary" />
|
|
152
|
+
{t("select_modules")}
|
|
153
|
+
</CardTitle>
|
|
154
|
+
<CardDescription>{t("select_modules_desc")}</CardDescription>
|
|
155
|
+
</CardHeader>
|
|
156
|
+
<CardContent className="space-y-4 max-h-[300px] overflow-y-auto">
|
|
157
|
+
{availableModules.map((mod) => (
|
|
158
|
+
<label
|
|
159
|
+
htmlFor={mod.id}
|
|
160
|
+
key={mod.id}
|
|
161
|
+
className="cursor-pointer flex items-start space-x-3 space-y-0 rounded-md border p-3"
|
|
162
|
+
>
|
|
163
|
+
<Checkbox
|
|
164
|
+
id={mod.id}
|
|
165
|
+
checked={selectedModules.includes(mod.id)}
|
|
166
|
+
onCheckedChange={() => toggleModule(mod.id)}
|
|
167
|
+
disabled={mod.system}
|
|
168
|
+
/>
|
|
169
|
+
<div className="grid gap-1.5 leading-none">
|
|
170
|
+
<div className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex items-center gap-2">
|
|
171
|
+
{mod.name}
|
|
172
|
+
{mod.system && (
|
|
173
|
+
<Badge variant="outline" className="text-[10px] h-4">
|
|
174
|
+
System
|
|
175
|
+
</Badge>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
<p className="text-xs text-muted-foreground">
|
|
179
|
+
{mod.description || "No description available."}
|
|
180
|
+
</p>
|
|
181
|
+
</div>
|
|
182
|
+
</label>
|
|
183
|
+
))}
|
|
184
|
+
</CardContent>
|
|
185
|
+
<CardFooter>
|
|
186
|
+
<Button onClick={nextStep} className="w-full">
|
|
187
|
+
{t("continue_to_admin")}
|
|
188
|
+
</Button>
|
|
189
|
+
</CardFooter>
|
|
190
|
+
</>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{step === 4 && (
|
|
194
|
+
<form
|
|
195
|
+
action={async (formData) => {
|
|
196
|
+
setLoading(true);
|
|
197
|
+
try {
|
|
198
|
+
await finishSetup(formData, selectedModules);
|
|
199
|
+
} catch (_e) {
|
|
200
|
+
// alert(String(e));
|
|
201
|
+
setLoading(false);
|
|
202
|
+
}
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<CardHeader>
|
|
206
|
+
<CardTitle className="flex items-center gap-2">
|
|
207
|
+
<UserPlus className="h-5 w-5 text-primary" />
|
|
208
|
+
{t("admin_account")}
|
|
209
|
+
</CardTitle>
|
|
210
|
+
<CardDescription>{t("admin_description")}</CardDescription>
|
|
211
|
+
</CardHeader>
|
|
212
|
+
<CardContent className="space-y-4">
|
|
213
|
+
<div className="space-y-2">
|
|
214
|
+
<label className="text-sm font-medium">{t("username")}</label>
|
|
215
|
+
<Input name="username" placeholder="admin" required />
|
|
216
|
+
</div>
|
|
217
|
+
<div className="space-y-2">
|
|
218
|
+
<label className="text-sm font-medium">{t("email")}</label>
|
|
219
|
+
<Input
|
|
220
|
+
name="email"
|
|
221
|
+
type="email"
|
|
222
|
+
placeholder="admin@kryo.io"
|
|
223
|
+
required
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
<div className="space-y-2">
|
|
227
|
+
<label className="text-sm font-medium">{t("password")}</label>
|
|
228
|
+
<Input name="password" type="password" required />
|
|
229
|
+
</div>
|
|
230
|
+
</CardContent>
|
|
231
|
+
<CardFooter className="mt-4">
|
|
232
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
233
|
+
{loading ? (
|
|
234
|
+
<>
|
|
235
|
+
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
|
236
|
+
{t("setting_up")}
|
|
237
|
+
</>
|
|
238
|
+
) : (
|
|
239
|
+
t("complete")
|
|
240
|
+
)}
|
|
241
|
+
</Button>
|
|
242
|
+
</CardFooter>
|
|
243
|
+
</form>
|
|
244
|
+
)}
|
|
245
|
+
</Card>
|
|
246
|
+
|
|
247
|
+
{/* Progress indicators */}
|
|
248
|
+
<div className="flex justify-center gap-2">
|
|
249
|
+
{[1, 2, 3, 4].map((s) => (
|
|
250
|
+
<div
|
|
251
|
+
key={s}
|
|
252
|
+
className={`h-1.5 w-8 rounded-full transition-colors ${
|
|
253
|
+
step >= s ? "bg-primary" : "bg-primary/10"
|
|
254
|
+
}`}
|
|
255
|
+
/>
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|