scale-stack 0.0.1-alpha.1 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +97 -3
- package/dist/index.js +4218 -16
- package/package.json +22 -10
- package/templates/ai-chat/chat-panel.tsx.ejs +70 -0
- package/templates/ai-chat/layout.tsx.ejs +39 -0
- package/templates/ai-chat/page.tsx.ejs +31 -0
- package/templates/ai-chat/route.ts.ejs +68 -0
- package/templates/ai-chat/use-chat.ts.ejs +26 -0
- package/templates/analytics/analytics-provider.tsx.ejs +32 -0
- package/templates/analytics/analytics.ts.ejs +40 -0
- package/templates/auth/auth-client.ts.ejs +7 -0
- package/templates/auth/auth.ts.ejs +45 -0
- package/templates/auth/route.ts.ejs +4 -0
- package/templates/auth/sign-in-page.tsx.ejs +122 -0
- package/templates/auth/sign-up-page.tsx.ejs +137 -0
- package/templates/auth/unauthorized.tsx.ejs +28 -0
- package/templates/core/layout.tsx.ejs +36 -0
- package/templates/core/loading.tsx.ejs +7 -0
- package/templates/core/next.config.ts.ejs +33 -0
- package/templates/error-handling/catch-all-not-found-page.tsx.ejs +5 -0
- package/templates/error-handling/error.tsx.ejs +33 -0
- package/templates/error-handling/global-error.tsx.ejs +32 -0
- package/templates/error-handling/not-found.tsx.ejs +28 -0
- package/templates/eslint-prettier/.prettierignore.ejs +29 -0
- package/templates/eslint-prettier/eslint.config.mjs.ejs +31 -0
- package/templates/form-handling/dashboard-page.tsx.ejs +33 -0
- package/templates/form-handling/example-form-action.ts.ejs +50 -0
- package/templates/form-handling/example-form-schema.ts.ejs +87 -0
- package/templates/form-handling/example-form.tsx.ejs +428 -0
- package/templates/i18n/ar.json.ejs +77 -0
- package/templates/i18n/en.json.ejs +77 -0
- package/templates/i18n/locale-layout.tsx.ejs +45 -0
- package/templates/i18n/navigation.ts.ejs +5 -0
- package/templates/i18n/next-intl.d.ts.ejs +9 -0
- package/templates/i18n/request.ts.ejs +15 -0
- package/templates/i18n/routing.ts.ejs +7 -0
- package/templates/orm/prisma.config.ts.ejs +12 -0
- package/templates/orm/prisma.ts.ejs +17 -0
- package/templates/orm/schema.prisma.ejs +8 -0
- package/templates/pre-commit/prek.toml.ejs +35 -0
- package/templates/proxy/proxy.ts.ejs +81 -0
- package/templates/server-actions/safe-action.ts.ejs +51 -0
- package/templates/ui/client-side-wrappers.tsx.ejs +19 -0
- package/templates/ui/page.tsx.ejs +111 -0
package/package.json
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scale-stack",
|
|
3
|
-
"version": "0.0.1
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "SKALSTÅKK — flat-packed code efficiency delivered straight to your terminal",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"
|
|
7
|
-
".": {
|
|
8
|
-
"import": "./dist/index.js"
|
|
9
|
-
}
|
|
10
|
-
},
|
|
6
|
+
"packageManager": "pnpm@10.33.0",
|
|
11
7
|
"bin": {
|
|
12
8
|
"scale-stack": "dist/index.js"
|
|
13
9
|
},
|
|
14
10
|
"files": [
|
|
15
|
-
"dist"
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"templates"
|
|
16
15
|
],
|
|
17
16
|
"publishConfig": {
|
|
18
17
|
"access": "public"
|
|
19
18
|
},
|
|
20
19
|
"scripts": {
|
|
21
20
|
"build": "tsup",
|
|
22
|
-
"
|
|
21
|
+
"run": "node dist/index.js",
|
|
22
|
+
"cli": "tsx src/cli/index.ts",
|
|
23
23
|
"test": "vitest run",
|
|
24
|
+
"test:e2e": "pnpm build && vitest run --config vitest.e2e.config.ts",
|
|
24
25
|
"test:watch": "vitest",
|
|
25
26
|
"lint": "eslint . && prettier --check .",
|
|
26
27
|
"fix": "eslint . --fix && prettier --write .",
|
|
@@ -28,7 +29,14 @@
|
|
|
28
29
|
"release": "pnpm build && npm publish",
|
|
29
30
|
"release:alpha": "pnpm build && npm publish --tag alpha"
|
|
30
31
|
},
|
|
31
|
-
"keywords": [
|
|
32
|
+
"keywords": [
|
|
33
|
+
"scale",
|
|
34
|
+
"stack",
|
|
35
|
+
"scaffolding",
|
|
36
|
+
"boilerplate",
|
|
37
|
+
"generator",
|
|
38
|
+
"cli"
|
|
39
|
+
],
|
|
32
40
|
"author": "balazs.farago@scale.com",
|
|
33
41
|
"license": "ISC",
|
|
34
42
|
"devDependencies": {
|
|
@@ -42,6 +50,7 @@
|
|
|
42
50
|
"jiti": "^2.6.1",
|
|
43
51
|
"prettier": "^3.8.2",
|
|
44
52
|
"tsup": "^8.5.1",
|
|
53
|
+
"tsx": "^4.21.0",
|
|
45
54
|
"typescript": "^6.0.2",
|
|
46
55
|
"typescript-eslint": "^8.58.2",
|
|
47
56
|
"vitest": "^4.1.4"
|
|
@@ -49,7 +58,10 @@
|
|
|
49
58
|
"dependencies": {
|
|
50
59
|
"@clack/prompts": "^1.2.0",
|
|
51
60
|
"commander": "^14.0.3",
|
|
61
|
+
"diff": "^9.0.0",
|
|
52
62
|
"ejs": "^5.0.2",
|
|
63
|
+
"es-toolkit": "^1.45.1",
|
|
64
|
+
"gradient-string": "^3.0.0",
|
|
53
65
|
"picocolors": "^1.1.1",
|
|
54
66
|
"semver": "^7.7.4"
|
|
55
67
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type FormEvent, Fragment, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Conversation,
|
|
6
|
+
ConversationContent,
|
|
7
|
+
ConversationScrollButton,
|
|
8
|
+
} from "@/components/ai-elements/conversation";
|
|
9
|
+
import {
|
|
10
|
+
Message,
|
|
11
|
+
MessageContent,
|
|
12
|
+
MessageResponse,
|
|
13
|
+
} from "@/components/ai-elements/message";
|
|
14
|
+
import { useChat } from "../_hooks/useChat";
|
|
15
|
+
|
|
16
|
+
export function ChatPanel() {
|
|
17
|
+
const [text, setText] = useState("");
|
|
18
|
+
const { messages, sendMessage, status } = useChat();
|
|
19
|
+
|
|
20
|
+
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
21
|
+
event.preventDefault();
|
|
22
|
+
|
|
23
|
+
const nextText = text.trim();
|
|
24
|
+
if (!nextText) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
void sendMessage({ text: nextText });
|
|
29
|
+
setText("");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex min-h-[540px] flex-1 flex-col rounded-lg border bg-background p-4 shadow-sm">
|
|
34
|
+
<Conversation className="flex-1">
|
|
35
|
+
<ConversationContent>
|
|
36
|
+
{messages.map((message) => (
|
|
37
|
+
<Message from={message.role} key={message.id}>
|
|
38
|
+
<MessageContent>
|
|
39
|
+
{message.parts.map((part, index) => (
|
|
40
|
+
<Fragment key={`${message.id}-${index}`}>
|
|
41
|
+
{part.type === "text" ? (
|
|
42
|
+
<MessageResponse>{part.text}</MessageResponse>
|
|
43
|
+
) : null}
|
|
44
|
+
</Fragment>
|
|
45
|
+
))}
|
|
46
|
+
</MessageContent>
|
|
47
|
+
</Message>
|
|
48
|
+
))}
|
|
49
|
+
</ConversationContent>
|
|
50
|
+
<ConversationScrollButton />
|
|
51
|
+
</Conversation>
|
|
52
|
+
|
|
53
|
+
<form className="mt-4 flex gap-2" onSubmit={handleSubmit}>
|
|
54
|
+
<textarea
|
|
55
|
+
className="min-h-20 flex-1 resize-none rounded-md border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
56
|
+
onChange={(event) => setText(event.currentTarget.value)}
|
|
57
|
+
placeholder="Ask the mock assistant anything..."
|
|
58
|
+
value={text}
|
|
59
|
+
/>
|
|
60
|
+
<button
|
|
61
|
+
className="self-end rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
|
62
|
+
disabled={!text.trim() || status === "streaming"}
|
|
63
|
+
type="submit"
|
|
64
|
+
>
|
|
65
|
+
{status === "streaming" ? "Sending..." : "Send"}
|
|
66
|
+
</button>
|
|
67
|
+
</form>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
<% if (i18n) { %>import { getTranslations } from "next-intl/server";
|
|
3
|
+
import { hasLocale } from "next-intl";
|
|
4
|
+
import { routing } from "@/i18n/routing";
|
|
5
|
+
<% } %>
|
|
6
|
+
|
|
7
|
+
<% if (i18n) { %>type ChatLayoutProps = Readonly<{
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
params: Promise<{ locale: string }>;
|
|
10
|
+
}>;
|
|
11
|
+
|
|
12
|
+
export async function generateMetadata({
|
|
13
|
+
params,
|
|
14
|
+
}: Pick<ChatLayoutProps, "params">): Promise<Metadata> {
|
|
15
|
+
const { locale: requestedLocale } = await params;
|
|
16
|
+
const locale = hasLocale(routing.locales, requestedLocale)
|
|
17
|
+
? requestedLocale
|
|
18
|
+
: routing.defaultLocale;
|
|
19
|
+
const t = await getTranslations({ locale, namespace: "Chat" });
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
title: t("metadataTitle"),
|
|
23
|
+
description: t("metadataDescription"),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
<% } else { %>
|
|
27
|
+
export const metadata: Metadata = {
|
|
28
|
+
title: "Chat",
|
|
29
|
+
description: "Deterministic AI SDK-compatible mock chat",
|
|
30
|
+
};
|
|
31
|
+
<% } %>
|
|
32
|
+
|
|
33
|
+
export default function ChatLayout({
|
|
34
|
+
children,
|
|
35
|
+
}: <% if (i18n) { %>ChatLayoutProps<% } else { %>Readonly<{
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
}><% } %>) {
|
|
38
|
+
return children;
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<% if (i18n) { %>import { getTranslations } from "next-intl/server";
|
|
2
|
+
|
|
3
|
+
<% } %>
|
|
4
|
+
import { ChatPanel } from "./_components/ChatPanel";
|
|
5
|
+
|
|
6
|
+
export default <% if (i18n) { %>async <% } %>function ChatPage() {
|
|
7
|
+
<% if (i18n) { %> const t = await getTranslations("Chat");
|
|
8
|
+
|
|
9
|
+
<% } %>
|
|
10
|
+
return (
|
|
11
|
+
<main className="flex flex-1 flex-col px-6 py-16">
|
|
12
|
+
<section className="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-5xl flex-col gap-6">
|
|
13
|
+
<div className="space-y-4">
|
|
14
|
+
<p className="text-sm font-medium tracking-[0.2em] text-muted-foreground uppercase">
|
|
15
|
+
<% if (i18n) { %>{t("eyebrow")}<% } else { %>AI Chat<% } %>
|
|
16
|
+
</p>
|
|
17
|
+
<div className="space-y-3">
|
|
18
|
+
<h1 className="max-w-3xl text-3xl font-semibold tracking-tight text-balance">
|
|
19
|
+
<% if (i18n) { %>{t("title")}<% } else { %>AI Elements and AI SDK are wired to a deterministic mock.<% } %>
|
|
20
|
+
</h1>
|
|
21
|
+
<p className="max-w-3xl text-muted-foreground text-balance">
|
|
22
|
+
<% if (i18n) { %>{t("description")}<% } else { %>The UI is ready for an external chat backend, but this scaffold
|
|
23
|
+
stays provider-free so the route works before credentials exist.<% } %>
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<ChatPanel />
|
|
28
|
+
</section>
|
|
29
|
+
</main>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createUIMessageStream,
|
|
3
|
+
createUIMessageStreamResponse,
|
|
4
|
+
type UIMessage,
|
|
5
|
+
} from "ai";
|
|
6
|
+
|
|
7
|
+
export const maxDuration = 30;
|
|
8
|
+
|
|
9
|
+
const SIMULATED_TOKEN_DELAY_MS = 35;
|
|
10
|
+
|
|
11
|
+
interface ChatRequestBody {
|
|
12
|
+
messages?: UIMessage[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sleep(ms: number): Promise<void> {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getLastUserText(messages: UIMessage[]): string {
|
|
20
|
+
const lastUserMessage = messages.findLast(
|
|
21
|
+
(message) => message.role === "user",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const text = lastUserMessage?.parts
|
|
25
|
+
.filter((part) => part.type === "text")
|
|
26
|
+
.map((part) => part.text)
|
|
27
|
+
.join(" ")
|
|
28
|
+
.trim();
|
|
29
|
+
|
|
30
|
+
return text || "your message";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function splitIntoSimulatedTokens(text: string): string[] {
|
|
34
|
+
return text.match(/\S+\s*/g) ?? [text];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function POST(req: Request) {
|
|
38
|
+
const body = (await req.json().catch(() => ({}))) as ChatRequestBody;
|
|
39
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
40
|
+
const lastUserText = getLastUserText(messages);
|
|
41
|
+
const responseText = `Mock response: I received "${lastUserText}". Replace this route with your external chat backend when ready.`;
|
|
42
|
+
|
|
43
|
+
// TODO: Replace this deterministic mock with the external chat backend proxy.
|
|
44
|
+
return createUIMessageStreamResponse({
|
|
45
|
+
stream: createUIMessageStream({
|
|
46
|
+
async execute({ writer }) {
|
|
47
|
+
writer.write({
|
|
48
|
+
type: "text-start",
|
|
49
|
+
id: "mock-response",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
for (const token of splitIntoSimulatedTokens(responseText)) {
|
|
53
|
+
writer.write({
|
|
54
|
+
type: "text-delta",
|
|
55
|
+
id: "mock-response",
|
|
56
|
+
delta: token,
|
|
57
|
+
});
|
|
58
|
+
await sleep(SIMULATED_TOKEN_DELAY_MS);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
writer.write({
|
|
62
|
+
type: "text-end",
|
|
63
|
+
id: "mock-response",
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useChat as useAiChat } from "@ai-sdk/react";
|
|
4
|
+
import { DefaultChatTransport, type UIMessage } from "ai";
|
|
5
|
+
|
|
6
|
+
const initialMessages: UIMessage[] = [
|
|
7
|
+
{
|
|
8
|
+
id: "mock-assistant-welcome",
|
|
9
|
+
role: "assistant",
|
|
10
|
+
parts: [
|
|
11
|
+
{
|
|
12
|
+
type: "text",
|
|
13
|
+
text: "Hi! This is a deterministic mock assistant. Send a message to verify the AI SDK transport before connecting a real backend.",
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function useChat() {
|
|
20
|
+
return useAiChat({
|
|
21
|
+
messages: initialMessages,
|
|
22
|
+
transport: new DefaultChatTransport({
|
|
23
|
+
api: "/api/chat",
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import Script from "next/script";
|
|
5
|
+
import { env } from "@/env";
|
|
6
|
+
|
|
7
|
+
export interface AnalyticsProviderProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
hasConsent?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function AnalyticsProvider({
|
|
13
|
+
children,
|
|
14
|
+
hasConsent = false,
|
|
15
|
+
}: AnalyticsProviderProps) {
|
|
16
|
+
if (!hasConsent) {
|
|
17
|
+
return <>{children}</>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
{children}
|
|
23
|
+
<Script id="plausible-analytics-bootstrap" strategy="afterInteractive">
|
|
24
|
+
{`window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init({captureOnLocalhost:${env.NEXT_PUBLIC_PLAUSIBLE_CAPTURE_LOCALHOST === "true"}})`}
|
|
25
|
+
</Script>
|
|
26
|
+
<Script
|
|
27
|
+
src={env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
|
28
|
+
strategy="afterInteractive"
|
|
29
|
+
/>
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface AnalyticsEvents {
|
|
2
|
+
page_viewed: undefined;
|
|
3
|
+
cta_clicked: {
|
|
4
|
+
location: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
};
|
|
7
|
+
form_submitted: {
|
|
8
|
+
formId: string;
|
|
9
|
+
success: boolean;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AnalyticsEventName = keyof AnalyticsEvents;
|
|
14
|
+
|
|
15
|
+
type PlausibleProperties = Record<string, string | number | boolean | null>;
|
|
16
|
+
|
|
17
|
+
type PlausibleEvent = (
|
|
18
|
+
eventName: string,
|
|
19
|
+
options?: { props?: PlausibleProperties },
|
|
20
|
+
) => void;
|
|
21
|
+
|
|
22
|
+
declare global {
|
|
23
|
+
interface Window {
|
|
24
|
+
plausible?: PlausibleEvent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function trackEvent<EventName extends AnalyticsEventName>(
|
|
29
|
+
eventName: EventName,
|
|
30
|
+
...args: AnalyticsEvents[EventName] extends undefined
|
|
31
|
+
? []
|
|
32
|
+
: [properties: AnalyticsEvents[EventName]]
|
|
33
|
+
): void {
|
|
34
|
+
if (typeof window === "undefined" || typeof window.plausible !== "function") {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const properties = args[0] as PlausibleProperties | undefined;
|
|
39
|
+
window.plausible(eventName, properties ? { props: properties } : undefined);
|
|
40
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth";
|
|
2
|
+
<% if (isStateful) { %>import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
3
|
+
import { prisma } from "@/lib/prisma";
|
|
4
|
+
<% } %>import { nextCookies } from "better-auth/next-js";
|
|
5
|
+
import { env } from "@/env";
|
|
6
|
+
|
|
7
|
+
export const auth = betterAuth({
|
|
8
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
9
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
10
|
+
advanced: {
|
|
11
|
+
disableCSRFCheck: false,
|
|
12
|
+
},
|
|
13
|
+
socialProviders: {
|
|
14
|
+
microsoft: {
|
|
15
|
+
clientId: env.MICROSOFT_CLIENT_ID,
|
|
16
|
+
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
|
17
|
+
tenantId: env.MICROSOFT_TENANT_ID,
|
|
18
|
+
authority: "https://login.microsoftonline.com",
|
|
19
|
+
prompt: "select_account",
|
|
20
|
+
// Create an app registration in Microsoft Entra admin center.
|
|
21
|
+
// Copy its Application (client) ID and client secret into env vars.
|
|
22
|
+
// Add this redirect URI for local dev:
|
|
23
|
+
// http://localhost:3000/api/auth/callback/microsoft
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
<% if (isStateful) { %> database: prismaAdapter(prisma, {
|
|
27
|
+
provider: "postgresql",
|
|
28
|
+
}),
|
|
29
|
+
emailAndPassword: {
|
|
30
|
+
enabled: true,
|
|
31
|
+
},
|
|
32
|
+
<% } else { %> session: {
|
|
33
|
+
cookieCache: {
|
|
34
|
+
enabled: true,
|
|
35
|
+
maxAge: 7 * 24 * 60 * 60,
|
|
36
|
+
strategy: "jwe",
|
|
37
|
+
refreshCache: true,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
account: {
|
|
41
|
+
storeStateStrategy: "cookie",
|
|
42
|
+
storeAccountCookie: true,
|
|
43
|
+
},
|
|
44
|
+
<% } %> plugins: [nextCookies()],
|
|
45
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
<% if (isStateful) { %>import type { FormEvent } from "react";
|
|
4
|
+
<% if (i18n) { %>import { Link } from "@/i18n/navigation";
|
|
5
|
+
<% } else { %>import Link from "next/link";
|
|
6
|
+
<% } %><% } %><% if (i18n) { %>import { useTranslations } from "next-intl";
|
|
7
|
+
<% } %>import { useState } from "react";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import {
|
|
10
|
+
Card,
|
|
11
|
+
CardContent,
|
|
12
|
+
CardDescription,
|
|
13
|
+
<% if (isStateful) { %> CardFooter,
|
|
14
|
+
<% } %> CardHeader,
|
|
15
|
+
CardTitle,
|
|
16
|
+
} from "@/components/ui/card";
|
|
17
|
+
<% if (isStateful) { %>import { Input } from "@/components/ui/input";
|
|
18
|
+
import { Label } from "@/components/ui/label";
|
|
19
|
+
<% } %>import { authClient } from "@/lib/auth-client";
|
|
20
|
+
|
|
21
|
+
export default function SignInPage() {
|
|
22
|
+
<% if (i18n) { %> const t = useTranslations("Auth");
|
|
23
|
+
<% } %>
|
|
24
|
+
<% if (isStateful) { %> const [email, setEmail] = useState("");
|
|
25
|
+
const [password, setPassword] = useState("");
|
|
26
|
+
<% } %> const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [pending, setPending] = useState(false);
|
|
28
|
+
|
|
29
|
+
async function signInWithMicrosoft() {
|
|
30
|
+
setPending(true);
|
|
31
|
+
setError(null);
|
|
32
|
+
const result = await authClient.signIn.social({
|
|
33
|
+
provider: "microsoft",
|
|
34
|
+
callbackURL: "/",
|
|
35
|
+
});
|
|
36
|
+
setPending(false);
|
|
37
|
+
|
|
38
|
+
if (result.error) {
|
|
39
|
+
setError(result.error.message ?? <% if (i18n) { %>t("microsoftSignInFailed")<% } else { %>"Microsoft sign-in failed."<% } %>);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
<% if (isStateful) { %>
|
|
43
|
+
async function signInWithEmail(event: FormEvent<HTMLFormElement>) {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
setPending(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
const result = await authClient.signIn.email({
|
|
48
|
+
email,
|
|
49
|
+
password,
|
|
50
|
+
callbackURL: "/",
|
|
51
|
+
});
|
|
52
|
+
setPending(false);
|
|
53
|
+
|
|
54
|
+
if (result.error) {
|
|
55
|
+
setError(result.error.message ?? <% if (i18n) { %>t("emailSignInFailed")<% } else { %>"Email sign-in failed."<% } %>);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
<% } %>
|
|
59
|
+
return (
|
|
60
|
+
<main className="flex flex-1 items-center justify-center px-6 py-24">
|
|
61
|
+
<Card className="w-full max-w-md">
|
|
62
|
+
<CardHeader>
|
|
63
|
+
<CardTitle><% if (i18n) { %>{t("signInTitle")}<% } else { %>Sign in<% } %></CardTitle>
|
|
64
|
+
<CardDescription>
|
|
65
|
+
<% if (i18n) { %>{t("<% if (isStateful) { %>signInDescriptionStateful<% } else { %>signInDescriptionStateless<% } %>")}<% } else { %>Use your Microsoft account<% if (isStateful) { %> or email and password<% } %> to continue.<% } %>
|
|
66
|
+
</CardDescription>
|
|
67
|
+
</CardHeader>
|
|
68
|
+
<CardContent className="space-y-6">
|
|
69
|
+
<Button
|
|
70
|
+
className="w-full"
|
|
71
|
+
type="button"
|
|
72
|
+
variant="outline"
|
|
73
|
+
onClick={signInWithMicrosoft}
|
|
74
|
+
disabled={pending}
|
|
75
|
+
>
|
|
76
|
+
<% if (i18n) { %>{t("continueWithMicrosoft")}<% } else { %>Continue with Microsoft<% } %>
|
|
77
|
+
</Button>
|
|
78
|
+
<% if (isStateful) { %>
|
|
79
|
+
<form className="space-y-4" onSubmit={signInWithEmail}>
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
<Label htmlFor="email"><% if (i18n) { %>{t("email")}<% } else { %>Email<% } %></Label>
|
|
82
|
+
<Input
|
|
83
|
+
id="email"
|
|
84
|
+
type="email"
|
|
85
|
+
autoComplete="email"
|
|
86
|
+
value={email}
|
|
87
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
88
|
+
required
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="space-y-2">
|
|
92
|
+
<Label htmlFor="password"><% if (i18n) { %>{t("password")}<% } else { %>Password<% } %></Label>
|
|
93
|
+
<Input
|
|
94
|
+
id="password"
|
|
95
|
+
type="password"
|
|
96
|
+
autoComplete="current-password"
|
|
97
|
+
value={password}
|
|
98
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
99
|
+
required
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
<Button className="w-full" type="submit" disabled={pending}>
|
|
103
|
+
<% if (i18n) { %>{t("signInWithEmail")}<% } else { %>Sign in with email<% } %>
|
|
104
|
+
</Button>
|
|
105
|
+
</form>
|
|
106
|
+
<% } %>
|
|
107
|
+
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
108
|
+
</CardContent>
|
|
109
|
+
<% if (isStateful) { %>
|
|
110
|
+
<CardFooter>
|
|
111
|
+
<p className="text-sm text-muted-foreground">
|
|
112
|
+
<% if (i18n) { %>{t("noAccount")}<% } else { %>No account?<% } %>{" "}
|
|
113
|
+
<Link className="font-medium text-foreground underline" href="/sign-up">
|
|
114
|
+
<% if (i18n) { %>{t("createOne")}<% } else { %>Create one<% } %>
|
|
115
|
+
</Link>
|
|
116
|
+
</p>
|
|
117
|
+
</CardFooter>
|
|
118
|
+
<% } %>
|
|
119
|
+
</Card>
|
|
120
|
+
</main>
|
|
121
|
+
);
|
|
122
|
+
}
|