Compare commits

...
17 Commits
Author SHA1 Message Date
LucasDLTG 36745cc9c4 feat: use api enpoints
CI / CI - Validation Technique (push) Successful in 35s
Publish & Deploy (Versioned) / Build and Push Docker Image (push) Successful in 27s
Publish & Deploy (Versioned) / Deploy via SSH (push) Successful in 8s
2026-05-07 20:55:01 +02:00
LucasDLTG ca3abb19e1 feat: use api enpoints
CI / CI - Validation Technique (push) Successful in 35s
Publish & Deploy (Versioned) / Build and Push Docker Image (push) Successful in 29s
Publish & Deploy (Versioned) / Deploy via SSH (push) Successful in 8s
2026-05-07 20:17:46 +02:00
LucasDLTG 7a910b3d8f feat add page to geneerate api key
CI / CI - Validation Technique (push) Successful in 37s
Publish & Deploy (Versioned) / Build and Push Docker Image (push) Successful in 29s
Publish & Deploy (Versioned) / Deploy via SSH (push) Successful in 8s
2026-05-07 18:49:05 +02:00
LucasDLTG bad3b5539c feat: add doc endpoint 2026-05-06 22:29:20 +02:00
LucasDLTG 1f7164ed2b feat: add sub to token 2026-05-06 22:17:29 +02:00
LucasDLTG b006b07e63 fix: auth token refresh 2026-05-06 10:43:31 +02:00
LucasDLTG 1fe5acc871 fix: ui chat
CI / CI - Validation Technique (push) Successful in 35s
2026-04-29 23:05:29 +02:00
LucasDLTG a5b2dc6fef fix: transtion for background 2026-04-29 21:05:16 +02:00
LucasDLTG df0428f240 fix: some design
CI / CI - Validation Technique (push) Successful in 35s
Publish & Deploy (Versioned) / Build and Push Docker Image (push) Successful in 27s
Publish & Deploy (Versioned) / Deploy via SSH (push) Successful in 8s
2026-04-29 15:37:02 +02:00
LucasDLTG 6e409592b5 fix: centered input + default stream 2026-04-29 15:12:44 +02:00
LucasDLTG 18f293ce67 feat: add streaming
CI / CI - Validation Technique (push) Successful in 36s
Publish & Deploy (Versioned) / Build and Push Docker Image (push) Successful in 27s
Publish & Deploy (Versioned) / Deploy via SSH (push) Successful in 8s
2026-04-29 13:35:21 +02:00
LucasDLTG d04fbbe73c fix: theme 2026-04-29 13:14:25 +02:00
LucasDLTG 11b5e6929a fix: notification on erro 2026-04-29 12:20:07 +02:00
LucasDLTG 07731e888c fix: sidebar opened by default on desktop 2026-04-29 11:05:25 +02:00
LucasDLTG 4e2a60b792 fix: language on home page 2026-04-29 10:59:00 +02:00
LucasDLTG df2c8aa766 fix: remove .env.production from dockerignore
CI / CI - Validation Technique (push) Successful in 35s
Publish & Deploy (Versioned) / Build and Push Docker Image (push) Successful in 28s
Publish & Deploy (Versioned) / Deploy via SSH (push) Successful in 8s
2026-04-28 19:50:03 +02:00
LucasDLTG 97cbc1d543 feat: public env var
CI / CI - Validation Technique (push) Successful in 35s
Publish & Deploy (Versioned) / Build and Push Docker Image (push) Successful in 13s
Publish & Deploy (Versioned) / Deploy via SSH (push) Successful in 7s
2026-04-28 19:27:16 +02:00
24 changed files with 667 additions and 405 deletions
+1
View File
@@ -7,4 +7,5 @@ Dockerfile
*.log *.log
docker-compose.yml docker-compose.yml
.env* .env*
!.env.production
.next/cache .next/cache
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=https://chat.iceberg.black/api/chat/v1
+3
View File
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.production
# husky # husky
.husky .husky
@@ -39,3 +40,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
src/app/api/auth/debug
+6 -1
View File
@@ -103,7 +103,7 @@ Change all lines with [TO CHANGE] + public/manifest.json
- Ensure all git secrets or variables exist - Ensure all git secrets or variables exist
- Ensure all docker links exist on distant machine - Ensure all docker links exist on distant machine
- Ensure env file exists on remote machine - Ensure env file exists on remote machine. If client needs access to env var put it in .env.production so var in included at build (DONT PUT SECRETS).
## Auto deploy ## Auto deploy
@@ -142,3 +142,8 @@ git tag -d v1.0.0; git push origin :refs/tags/v1.0.0; git tag -a v1.0.0 -m "Rele
## To implement ## To implement
- Token refresh - Token refresh
- No error when streaming with too large model
- Translations
- When user stop generation, forward it to server
- Message UI
- UX (eg keep focus when press enter)
+1 -1
View File
@@ -25,7 +25,7 @@
}, },
"home": { "home": {
"name": "Iceberg Server", "name": "Iceberg Server",
"welcome": "Welcome to your Vite + React + Tailwind app", "welcome": "Welcome to Iceberg Chat. Log in to start.",
"start": "Get Started" "start": "Get Started"
} }
} }
+1 -1
View File
@@ -25,7 +25,7 @@
}, },
"home": { "home": {
"name": "Iceberg Server", "name": "Iceberg Server",
"welcome": "Bienvenue sur votre application Vite + React + Tailwind", "welcome": "Bienvenue sur Iceberg Chat. Veuillez vous connecter pour continuer.",
"start": "Commencer" "start": "Commencer"
} }
} }
+53 -230
View File
@@ -1,243 +1,66 @@
// 'use client' "use client";
// import { useEffect, useState } from 'react' import { useState } from "react";
import { useSession } from "next-auth/react";
// export default function AboutPage() { export default function GenerateKeyButton() {
// const [data, setData] = useState<any>(null) const [loading, setLoading] = useState(false);
// const [loading, setLoading] = useState(true) const [result, setResult] = useState<string | null>(null);
// useEffect(() => { const { data: session, status } = useSession();
// fetch((process.env.NEXT_PUBLIC_API_URL as string) + "/docs.json")
// .then((res) => res.json())
// .then((result) => {
// setData(result)
// setLoading(false)
// })
// .catch((err) => {
// console.error(err)
// setLoading(false)
// })
// }, [])
// return ( async function handleGenerate() {
// <div className="p-6"> setLoading(true);
setResult(null);
// {loading && <p>Loading...</p>} try {
const res = await fetch(process.env.NEXT_PUBLIC_API_URL! + '/keys/generate', {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.accessToken}`,
// "x-api-key": "Q67lQp7NZY2f2Zy6DXYbFoauDMjw79FBD8LPn9ee6OM"
},
body: JSON.stringify({
name: "my-api-key",
scopes: ["read", "write"],
}),
});
// {!loading && data && ( if (!res.ok) {
// <pre>{JSON.stringify(data, null, 2)}</pre> const errText = await res.text();
// )} throw new Error(errText);
// </div> }
// )
// }
// 'use client'; const data = await res.json();
setResult(JSON.stringify(data, null, 2));
// import { useEffect, useState } from 'react'; } catch (err: any) {
// import Spinner from '@/components/spinner'; setResult(err.message);
} finally {
// export default function AboutPage() { setLoading(false);
// const [data, setData] = useState<any>(null); }
// const [loading, setLoading] = useState(true); }
// useEffect(() => {
// fetch((process.env.NEXT_PUBLIC_API_URL as string) + '/docs.json')
// .then((res) => res.json())
// .then((result) => {
// setData(result);
// setLoading(false);
// })
// .catch(() => {
// setLoading(false);
// });
// }, []);
// if (loading) {
// return (
// <div className="flex items-center justify-center min-h-screen">
// <div className="h-12 w-12 rounded-full border-4 border-gray-300 border-t-blue-600 animate-spin" />
// </div>
// );
// }
// return (
// <div className="flex items-center justify-center min-h-screen bg-primary text-primary transition-theme">
// <Spinner />
// </div>
// );
// }
'use client';
import Sidebar from '@/components/Sidebar';
import { SidebarPanel } from '@/components/Sidebar';
import {
Slider,
NumberInput,
TextInput,
Toggle,
} from '../../../components/Inputs';
type PanelProps = {
settings: Settings;
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
};
export type Settings = {
temp: number;
topP: number;
topK: number | undefined;
maxTokens: number | undefined;
numCtx: number | undefined;
stop: string[] | undefined;
repeatPenalty: number;
seed: number | undefined;
stream: boolean;
keepAlive: string | undefined;
};
function SettingsPanel({ settings, setSettings }: PanelProps) {
const set =
<K extends keyof Settings>(key: K) =>
(val: Settings[K]) =>
setSettings((prev) => ({ ...prev, [key]: val }));
const sections = [
{
title: 'Sampling',
fields: (s: any, set: any) => (
<>
<Slider
label="Temperature"
value={s.temp}
setValue={set('temp')}
min={0}
max={2}
step={0.1}
/>
<Slider
label="Top P"
value={s.topP}
setValue={set('topP')}
min={0}
max={1}
step={0.05}
/>
<NumberInput label="Top K" value={s.topK} setValue={set('topK')} />
</>
),
},
{
title: 'Generation',
fields: (s: any, set: any) => (
<>
<NumberInput
label="Max Tokens"
value={s.maxTokens}
setValue={set('maxTokens')}
/>
<NumberInput
label="Context Size"
value={s.numCtx}
setValue={set('numCtx')}
/>
<TextInput
label="Stop Sequences"
value={s.stop?.join(', ')}
setValue={(val) =>
set('stop')(val ? val.split(',').map((s) => s.trim()) : undefined)
}
placeholder="Comma separated"
/>
</>
),
},
{
title: 'Control',
fields: (s: any, set: any) => (
<>
<Slider
label="Repeat Penalty"
value={s.repeatPenalty}
setValue={set('repeatPenalty')}
min={0}
max={2}
step={0.1}
/>
<NumberInput label="Seed" value={s.seed} setValue={set('seed')} />
</>
),
},
{
title: 'System',
fields: (s: any, set: any) => (
<>
<Toggle label="Streaming" value={s.stream} setValue={set('stream')} />
<TextInput
label="Keep Alive"
value={s.keepAlive}
setValue={set('keepAlive')}
placeholder="e.g. 5m"
/>
</>
),
},
];
return ( return (
<div className="space-y-6 px-1"> <div style={{ padding: 20 }}>
{sections.map((section) => ( <button
<div key={section.title} className="space-y-4"> onClick={handleGenerate}
<h3 className="text-xs uppercase text-accent tracking-widest"> disabled={loading}
{section.title} style={{
</h3> padding: "10px 16px",
background: "black",
color: "white",
borderRadius: 6,
}}
>
{loading ? "Generating..." : "Generate API Key"}
</button>
{section.fields(settings, set)} {result && (
</div> <pre style={{ marginTop: 20, background: "#eee", padding: 10 }}>
))} {result}
</div> </pre>
); )}
}
export const SIDEBAR_PANELS = ({
settings,
setSettings,
}: PanelProps): SidebarPanel[] => [
{
id: 'settings',
label: 'Settings',
icon: (
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path
fillRule="evenodd"
d="M7.84 1.804A1 1 0 018.82 1h2.36a1 1 0 01.98.804l.331 1.652a6.993 6.993 0 011.929 1.115l1.598-.54a1 1 0 011.186.447l1.18 2.044a1 1 0 01-.205 1.251l-1.267 1.113a7.047 7.047 0 010 2.228l1.267 1.113a1 1 0 01.205 1.251l-1.18 2.044a1 1 0 01-1.186.447l-1.598-.54a6.993 6.993 0 01-1.929 1.115l-.33 1.652a1 1 0 01-.98.804H8.82a1 1 0 01-.98-.804l-.331-1.652a6.993 6.993 0 01-1.929-1.115l-1.598.54a1 1 0 01-1.186-.447l-1.18-2.044a1 1 0 01.205-1.251l1.267-1.114a7.05 7.05 0 010-2.227L1.821 7.773a1 1 0 01-.206-1.25l1.18-2.045a1 1 0 011.187-.447l1.598.54A6.993 6.993 0 017.51 3.456l.33-1.652zM10 13a3 3 0 100-6 3 3 0 000 6z"
clipRule="evenodd"
/>
</svg>
),
component: <SettingsPanel settings={settings} setSettings={setSettings} />,
},
];
import { useState } from 'react';
export default function DocPage() {
const [settings, setSettings] = useState<Settings>({
temp: 0.7,
topP: 0.9,
topK: 40,
maxTokens: 1000,
numCtx: 128000,
stop: [''],
repeatPenalty: 1.1,
seed: 0,
stream: true,
keepAlive: '5m',
});
return (
<div className="flex flex-1 overflow-hidden min-h-0">
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
</div> </div>
); );
} }
+9 -6
View File
@@ -7,8 +7,9 @@ import { routing } from '@/i18n/routing';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import { Providers } from '../providers'; import { Providers } from '../providers';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { authOptions } from '@/lib/auth';
import SessionGuard from '@/components/SessionGuard';
export const metadata = { export const metadata = {
title: 'Iceberg template', title: 'Iceberg template',
@@ -42,13 +43,15 @@ export default async function RootLayout(props: {
<html lang={locale} className={theme === 'dark' ? 'dark' : 'light-mode'}> <html lang={locale} className={theme === 'dark' ? 'dark' : 'light-mode'}>
<body className="h-screen flex flex-col bg-primary text-primary transition-theme"> <body className="h-screen flex flex-col bg-primary text-primary transition-theme">
<Providers locale={locale} messages={messages} session={session}> <Providers locale={locale} messages={messages} session={session}>
<Navbar /> <SessionGuard>
<Navbar />
<main className="flex-1 pt-[var(--header-height)] flex overflow-hidden"> <main className="flex-1 pt-[var(--header-height)] flex overflow-hidden">
{props.children} {props.children}
</main> </main>
<Footer /> <Footer />
</SessionGuard>
</Providers> </Providers>
</body> </body>
</html> </html>
+1 -1
View File
@@ -64,7 +64,7 @@ export default function Home() {
if (!isLoggedIn) { if (!isLoggedIn) {
return ( return (
<div className="flex flex-col items-center justify-center"> <div className="flex flex-1 flex-col items-center justify-center">
<h1 className="text-5xl font-bold text-cyan-400">{t('name')}</h1> <h1 className="text-5xl font-bold text-cyan-400">{t('name')}</h1>
<p className="mt-4 text-gray-400">{t('welcome')}</p> <p className="mt-4 text-gray-400">{t('welcome')}</p>
<button <button
+9
View File
@@ -0,0 +1,9 @@
import { env } from "process";
export async function GET() {
const res = await fetch(env.NEXT_PUBLIC_API_URL! + "/docs.json");
const data = await res.json();
return Response.json(data);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export function Providers({
messages: AbstractIntlMessages; messages: AbstractIntlMessages;
}) { }) {
return ( return (
<SessionProvider session={session}> <SessionProvider session={session} refetchInterval={4 * 60} refetchOnWindowFocus={true}>
<NextIntlClientProvider <NextIntlClientProvider
locale={locale} locale={locale}
messages={messages} messages={messages}
+5 -5
View File
@@ -26,7 +26,7 @@ export function Slider({
step={step} step={step}
value={value} value={value}
onChange={(e) => setValue(Number(e.target.value))} onChange={(e) => setValue(Number(e.target.value))}
className="flex-1 accent-violet-400" className="flex-1 accent-[var(--accent2)]"
/> />
<span className="text-sm w-10 text-right">{value}</span> <span className="text-sm w-10 text-right">{value}</span>
</div> </div>
@@ -50,7 +50,7 @@ export function NumberInput({ label, value, setValue }: NumberInputProps) {
onChange={(e) => onChange={(e) =>
setValue(e.target.value ? Number(e.target.value) : undefined) setValue(e.target.value ? Number(e.target.value) : undefined)
} }
className="w-full mt-2 p-2 rounded bg-black/20 border border-muted" className="w-full mt-2 p-2 rounded bg-secondary border border-muted"
/> />
</div> </div>
); );
@@ -77,7 +77,7 @@ export function TextInput({
value={value ?? ''} value={value ?? ''}
onChange={(e) => setValue(e.target.value || undefined)} onChange={(e) => setValue(e.target.value || undefined)}
placeholder={placeholder} placeholder={placeholder}
className="w-full mt-2 p-2 rounded bg-black/20 border border-muted" className="w-full mt-2 p-2 rounded bg-secondary border border-muted"
/> />
</div> </div>
); );
@@ -95,8 +95,8 @@ export function Toggle({ label, value, setValue }: ToggleProps) {
<span className="text-sm">{label}</span> <span className="text-sm">{label}</span>
<button <button
onClick={() => setValue(!value)} onClick={() => setValue(!value)}
className={`w-10 h-5 rounded-full ${ className={`w-10 h-5 rounded-full border border-muted ${
value ? 'bg-violet-500' : 'bg-white/20' value ? 'bg-[var(--accent2)]' : 'bg-secondary'
} relative`} } relative`}
> >
<span <span
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useSession, signIn } from "next-auth/react";
import { useEffect, useRef } from "react";
import Spinner from "./Spinner";
export default function SessionGuard({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const triedSilent = useRef(false);
useEffect(() => {
if (status === "unauthenticated" && !triedSilent.current) {
triedSilent.current = true;
signIn("keycloak", undefined, { prompt: "none" });
}
if (
session?.error === "RefreshTokenExpired" ||
session?.error === "RefreshAccessTokenError"
) {
signIn("keycloak", undefined, { prompt: "none" });
}
}, [status, session?.error]);
if (status === "loading") {
return <Spinner />
}
return <>{children}</>;
}
+19 -20
View File
@@ -24,21 +24,21 @@ function useIsWide(): boolean {
/* ----------------------------- /* -----------------------------
Shared sidebar logic Shared sidebar logic
------------------------------*/ ------------------------------*/
function useSidebarState() { function useSidebarState(panels: SidebarPanel[], isWide: boolean) {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
// open first panel by default ONLY on first mount (desktop only)
const [initialized, setInitialized] = useState(false);
if (!initialized && isWide) {
setActiveId(panels[0]?.id ?? null);
setInitialized(true);
}
const setClosed = () => setActiveId(null); const setClosed = () => setActiveId(null);
const toggle = (id: string, effectiveId: string | null) => { const toggle = (id: string) => {
setActiveId((prev) => setActiveId((prev) => (prev === id ? null : id));
prev === id || (effectiveId === id && prev === null) ? null : id,
);
};
const resolveEffectiveId = (isWide: boolean, panels: SidebarPanel[]) => {
return activeId === null
? null
: (activeId ?? (isWide ? (panels[0]?.id ?? null) : null));
}; };
return { return {
@@ -46,7 +46,6 @@ function useSidebarState() {
setActiveId, setActiveId,
setClosed, setClosed,
toggle, toggle,
resolveEffectiveId,
}; };
} }
@@ -65,13 +64,13 @@ function DesktopSidebar({
return ( return (
<div <div
className={` className={`
flex transition-all duration-300 ease-in-out overflow-hidden flex transition-all duration-300 ease overflow-hidden
${isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'} ${isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'}
`} `}
> >
{activePanel && ( {activePanel && (
<div className="w-64 flex flex-col border-r border-muted"> <div className="w-64 flex flex-col border-r border-muted transition-theme">
<div className="flex items-center justify-between px-4 py-3 border-b border-muted shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-b border-muted shrink-0 transition-theme">
<span className="text-sm font-semibold text-accent"> <span className="text-sm font-semibold text-accent">
{activePanel.label} {activePanel.label}
</span> </span>
@@ -142,8 +141,8 @@ function IconRail({
key={panel.id} key={panel.id}
onClick={() => toggle(panel.id, effectiveId)} onClick={() => toggle(panel.id, effectiveId)}
title={panel.label} title={panel.label}
className={`w-8 h-8 rounded-lg flex items-center justify-center transition-theme ${ className={`w-8 h-8 rounded-lg flex items-center justify-center text-secondary ${
effectiveId === panel.id ? 'bg-accent2' : '' effectiveId === panel.id ? 'border-2 border-[var(--accent)]' : ''
}`} }`}
> >
{panel.icon} {panel.icon}
@@ -159,14 +158,14 @@ function IconRail({
export default function Sidebar({ panels }: { panels: SidebarPanel[] }) { export default function Sidebar({ panels }: { panels: SidebarPanel[] }) {
const isWide = useIsWide(); const isWide = useIsWide();
const { setClosed, toggle, resolveEffectiveId } = useSidebarState(); const { activeId, setClosed, toggle } = useSidebarState(panels, isWide);
const effectiveId = resolveEffectiveId(isWide, panels); const effectiveId = activeId;
const activePanel = panels.find((p) => p.id === effectiveId); const activePanel = panels.find((p) => p.id === effectiveId);
const isOpen = !!activePanel; const isOpen = !!activePanel;
return ( return (
<div className="flex border-r border-t border-muted"> <div className="flex border-r border-t border-muted transition-theme">
{/* Desktop */} {/* Desktop */}
{isWide && ( {isWide && (
<DesktopSidebar <DesktopSidebar
+192 -62
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef } from 'react';
import ModelSelect from './ModelSelect'; import ModelSelect from './ModelSelect';
import Sidebar from '../Sidebar'; import Sidebar from '../Sidebar';
import { SIDEBAR_PANELS } from './chat.panels'; import { SIDEBAR_PANELS } from './chat.panels';
@@ -8,6 +8,9 @@ import { Session } from 'next-auth';
import { Message, ModelInfo } from '@/types/chat-api'; import { Message, ModelInfo } from '@/types/chat-api';
import { ChatRequest } from '@/types/chat-api'; import { ChatRequest } from '@/types/chat-api';
import { Settings } from './chat.panels'; import { Settings } from './chat.panels';
import { fetchChatStream, readStreamChunks } from './stream';
import { MessageIcon } from '../icons/MessageIcon';
import { CancelIcon } from '../icons/CancelIcon';
type Props = { type Props = {
session: Session; session: Session;
@@ -16,6 +19,9 @@ type Props = {
setSelectedModel: (v: string) => void; setSelectedModel: (v: string) => void;
}; };
// Sentinel role used while the assistant message is being streamed in
const ASSISTANT_ROLE = 'assistant' as const;
export default function ChatUI({ export default function ChatUI({
session, session,
models, models,
@@ -35,74 +41,152 @@ export default function ChatUI({
stop: [''], stop: [''],
repeatPenalty: 1.1, repeatPenalty: 1.1,
seed: 0, seed: 0,
stream: false, stream: true,
keepAlive: '5m', keepAlive: '5m',
}); });
const showError = (message: string) => {
setMessages((prev) => [
...prev,
{ role: ASSISTANT_ROLE, content: `❌ ${message}` },
]);
setNotification(message);
setTimeout(() => setNotification(null), 3000);
};
const sendMessage = async () => { const sendMessage = async () => {
if (!input.trim() || !selectedModel) return; if (!input.trim() || !selectedModel) return;
const userMessage = { role: 'user' as const, content: input };
const newMessages = [...messages, userMessage]; const userMessage: Message = { role: 'user', content: input };
const newMessages: Message[] = [...messages, userMessage];
setMessages(newMessages); setMessages(newMessages);
setInput(''); setInput('');
setLoading(true); setLoading(true);
try { const body: ChatRequest = {
const body: ChatRequest = { model: selectedModel,
model: selectedModel!, messages: newMessages,
messages: newMessages, temperature: settings.temp,
temperature: settings.temp, top_p: settings.topP,
top_p: settings.topP, top_k: settings.topK,
top_k: settings.topK, num_predict: settings.maxTokens,
num_predict: settings.maxTokens, num_ctx: settings.numCtx,
num_ctx: settings.numCtx, repeat_penalty: settings.repeatPenalty,
repeat_penalty: settings.repeatPenalty, seed: settings.seed,
seed: settings.seed, stream: settings.stream,
stream: settings.stream, keep_alive: settings.keepAlive,
keep_alive: settings.keepAlive, stop: settings.stop,
stop: settings.stop, };
};
const res = await fetch( const endpoint = process.env.NEXT_PUBLIC_API_URL + '/chat/completions';
process.env.NEXT_PUBLIC_API_URL + '/chat/completions',
{ try {
method: 'POST', if (settings.stream) {
headers: { await handleStream(endpoint, body, newMessages);
'Content-Type': 'application/json', } else {
Authorization: `Bearer ${session?.accessToken}`, await handleNonStream(endpoint, body);
}, }
body: JSON.stringify(body),
},
);
const data = await res.json();
const assistant = data?.choices?.[0]?.message;
if (assistant) setMessages((prev) => [...prev, assistant]);
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
setNotification( showError(err?.message ?? 'Something went wrong while sending request');
err?.message || 'Something went wrong while sending request',
);
setTimeout(() => setNotification(null), 3000);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// ── Non-streaming path ────────────────────────────────────────────────────
const handleNonStream = async (endpoint: string, body: ChatRequest) => {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify(body),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
let message = 'Request failed';
if (res.status === 500)
message = 'Server error (likely model too large or OOM)';
else if (res.status === 401) message = 'Unauthorized';
else message = data?.message ?? data?.error ?? message;
throw new Error(message);
}
const assistant = data?.choices?.[0]?.message;
if (assistant) {
setMessages((prev) => [...prev, assistant]);
}
};
// ── Streaming path ────────────────────────────────────────────────────────
const abortRef = useRef<AbortController | null>(null);
const stopStream = () => {
abortRef.current?.abort();
};
const handleStream = async (
endpoint: string,
body: ChatRequest,
priorMessages: Message[],
) => {
const placeholderIndex = priorMessages.length;
setMessages((prev) => [...prev, { role: ASSISTANT_ROLE, content: '' }]);
const controller = new AbortController();
abortRef.current = controller;
const response = await fetchChatStream(
endpoint,
body,
session?.accessToken as string,
controller.signal,
);
try {
for await (const { content, done } of readStreamChunks(response)) {
if (done) break;
if (!content) continue;
setMessages((prev) => {
const next = [...prev];
const target = next[placeholderIndex];
if (target) {
next[placeholderIndex] = {
...target,
content: target.content + content,
};
}
return next;
});
}
} catch (err: any) {
// AbortError is expected when the user clicks Stop — don't rethrow
if (err?.name !== 'AbortError') throw err;
} finally {
abortRef.current = null;
}
};
// ── Render ────────────────────────────────────────────────────────────────
return ( return (
<div className="flex flex-1 bg-primary text-primary transition-theme"> <div className="flex flex-1 bg-primary text-primary transition-theme relative">
{notification && ( {notification && (
<div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50"> <div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50">
{notification} {notification}
</div> </div>
)} )}
{/* ── Sidebar (icon rail + sliding panel) ── */}
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} /> <Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
{/* ── Main chat area ── */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
{/* Top bar */}
<header className="p-4 flex justify-between items-center border-b border-white/10"> <header className="p-4 flex justify-between items-center border-b border-white/10">
<ModelSelect <ModelSelect
models={models} models={models}
@@ -111,30 +195,76 @@ export default function ChatUI({
/> />
</header> </header>
{/* Messages */}
<main className="flex-1 overflow-y-auto flex flex-col p-6"> <main className="flex-1 overflow-y-auto flex flex-col p-6">
<div className="w-full max-w-2xl mx-auto space-y-3"> {messages.length === 0 ? (
{messages.map((msg, i) => ( <div className="flex-1 flex flex-col items-center justify-center">
<div key={i} className="text-sm"> <p className="text-primary text-sm mb-3">Start a conversation…</p>
<b>{msg.role === 'user' ? 'You' : 'AI'}:</b> {msg.content} <input
</div> value={input}
))} onChange={(e) => setInput(e.target.value)}
{loading && <div className="text-xs text-muted">Thinking...</div>} onKeyDown={(e) =>
</div> e.key === 'Enter' && !loading && sendMessage()
}
disabled={loading}
className="w-full max-w-2xl p-3 rounded-xl bg-secondary border border-muted outline-none disabled:opacity-50"
placeholder="Message..."
/>
</div>
) : (
<div className="w-full max-w-2xl mx-auto space-y-3">
{messages.map((msg, i) => (
<div key={i} className="text-sm">
<b>{msg.role === 'user' ? 'You' : 'AI'}:</b> {msg.content}
{loading &&
settings.stream &&
i === messages.length - 1 &&
msg.role === ASSISTANT_ROLE && (
<span className="inline-block w-[2px] h-[1em] bg-current ml-0.5 align-middle animate-pulse" />
)}
</div>
))}
{loading && !settings.stream && (
<div className="text-xs text-muted">Thinking...</div>
)}
</div>
)}
</main> </main>
{/* Input */} {messages.length > 0 && (
<div className="p-4 border-t border-white/10"> <div className="p-4">
<div className="w-full max-w-2xl mx-auto"> <div className="flex items-center w-full max-w-2xl mx-auto relative">
<input <input
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()} onKeyDown={(e) =>
className="w-full p-3 rounded-xl bg-black/20 border border-muted outline-none" e.key === 'Enter' && !loading && sendMessage()
placeholder="Message..." }
/> className="w-full p-3 pr-12 rounded-xl bg-secondary border border-muted outline-none"
placeholder="Message..."
/>
<div className="absolute right-2 flex items-center">
{loading ? (
<button
onClick={stopStream}
className="group p-2 rounded-lg"
title="Stop generation"
>
<CancelIcon className="group-hover:text-[var(--accent)]" />
</button>
) : (
<button
onClick={sendMessage}
disabled={!input.trim() || !selectedModel}
className="group p-2 rounded-lg disabled:opacity-30 disabled:pointer-events-none"
title="Send"
>
<MessageIcon className="group-hover:group-disabled:hover:text-inherit group-hover:text-[var(--accent)]" />
</button>
)}
</div>
</div>
</div> </div>
</div> )}
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -31,7 +31,7 @@ export default function ModelSelect({
{/* Trigger */} {/* Trigger */}
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 bg-secondary border border-muted rounded-lg px-3 py-2 text-sm text-primary transition-fast" className="flex items-center gap-2 bg-secondary border border-muted rounded-lg px-3 py-2 text-sm text-primary transition-theme"
> >
<span>{selectedModel || 'Select model'}</span> <span>{selectedModel || 'Select model'}</span>
+1 -1
View File
@@ -17,7 +17,7 @@ function ConversationHistoryPanel() {
className="flex flex-col px-3 py-2 rounded-lg hover:bg-white/10 cursor-pointer transition-colors" className="flex flex-col px-3 py-2 rounded-lg hover:bg-white/10 cursor-pointer transition-colors"
> >
<span className="text-sm font-medium truncate">{h.title}</span> <span className="text-sm font-medium truncate">{h.title}</span>
<span className="text-xs text-white/40">{h.time}</span> <span className="text-xs text-secondary">{h.time}</span>
</li> </li>
))} ))}
</ul> </ul>
+123
View File
@@ -0,0 +1,123 @@
/**
* chat.stream.ts
*
* Helpers for consuming a streaming chat-completions response (SSE format).
* The server must send chunks as:
* data: {"choices":[{"delta":{"content":"..."}}]}\n\n
* data: [DONE]\n\n
*/
export type StreamChunk = {
content: string;
done: boolean;
};
/**
* Async-generator that yields text deltas from a streaming fetch response.
* Handles both pure SSE (`data: ...`) and raw JSON-lines.
*
* @example
* for await (const { content, done } of readStreamChunks(response)) {
* if (done) break;
* setPartial(prev => prev + content);
* }
*/
export async function* readStreamChunks(
response: Response,
): AsyncGenerator<StreamChunk> {
if (!response.body) {
throw new Error('Response body is null — cannot stream.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
// Flush any remaining buffer content
if (buffer.trim()) {
yield* parseBuffer(buffer);
}
break;
}
buffer += decoder.decode(value, { stream: true });
// Split on double-newline (SSE event boundary) or single newline (JSON-lines)
const parts = buffer.split(/\n\n|\n/);
// Keep the last (potentially incomplete) chunk in the buffer
buffer = parts.pop() ?? '';
for (const part of parts) {
yield* parseBuffer(part);
}
}
}
/**
* Parses a single SSE line or JSON line and yields a StreamChunk.
*/
function* parseBuffer(raw: string): Generator<StreamChunk> {
const line = raw.trim();
if (!line) return;
// SSE lines start with "data: "
const jsonStr = line.startsWith('data: ') ? line.slice(6) : line;
if (jsonStr === '[DONE]') {
yield { content: '', done: true };
return;
}
try {
const parsed = JSON.parse(jsonStr);
const delta = parsed?.choices?.[0]?.delta?.content;
if (typeof delta === 'string') {
yield { content: delta, done: false };
}
} catch {
// Not valid JSON — silently skip (could be a comment or keep-alive ping)
}
}
/**
* Builds the streaming fetch request and returns the raw Response.
* Throws on non-2xx status with a descriptive message.
*/
export async function fetchChatStream(
url: string,
body: object,
token: string,
signal?: AbortSignal,
): Promise<Response> {
const res = await fetch(url, {
method: 'POST',
signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
let message = 'Request failed';
if (res.status === 500) {
message = 'Server error (likely model too large or OOM)';
} else if (res.status === 401) {
message = 'Unauthorized';
} else {
const data = await res.json().catch(() => null);
message = data?.message ?? data?.error ?? message;
}
throw new Error(message);
}
return res;
}
+11
View File
@@ -0,0 +1,11 @@
export const CancelIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
className={`w-4 h-4 ${props.className ?? ''}`}
>
<rect x="4" y="4" width="16" height="16" rx="2" />
</svg>
);
+18
View File
@@ -0,0 +1,18 @@
export function MessageIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
className={`w-4 h-4 ${props.className ?? ''}`}
>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
);
}
+23 -3
View File
@@ -24,9 +24,10 @@ body {
--footer-height: 70px; --footer-height: 70px;
--bg-primary: #000000; --bg-primary: #000000;
--bg-secondary: oklch(27.4% 0.006 286.033); --bg-secondary: #27272a;
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: #bbbbbb;
--accent: #00d4ff; --accent: #00d4ff;
--accent2: #7b2ff7; --accent2: #7b2ff7;
@@ -47,13 +48,14 @@ body {
.light-mode { .light-mode {
--bg-primary: #e0e0e0; --bg-primary: #e0e0e0;
--bg-secondary: oklch(96.7% 0.001 286.375); --bg-secondary: #dadada;
--text-primary: #111111; --text-primary: #111111;
--text-secondary: #666666;
--accent: #0077ff; --accent: #0077ff;
--muted-color: #ffffff33; --muted-color: #88888833;
--color-base: #ffffff; --color-base: #ffffff;
--color-inverse: #000000; --color-inverse: #000000;
@@ -83,6 +85,10 @@ body {
color: var(--text-primary); color: var(--text-primary);
} }
.text-secondary {
color: var(--text-secondary);
}
.text-accent { .text-accent {
color: var(--accent); color: var(--accent);
} }
@@ -103,9 +109,23 @@ body {
TRANSITIONS TRANSITIONS
========================= */ ========================= */
@property --bg-secondary {
syntax: '<color>';
inherits: true;
initial-value: transparent;
}
@property --bg-primary {
syntax: '<color>';
inherits: true;
initial-value: transparent;
}
@layer utilities { @layer utilities {
.transition-theme { .transition-theme {
transition: transition:
--bg-secondary 500ms ease,
--bg-primary 500ms ease,
color 500ms ease, color 500ms ease,
background-color 500ms ease, background-color 500ms ease,
border-color 500ms ease; border-color 500ms ease;
+156 -69
View File
@@ -1,30 +1,34 @@
import { NextAuthOptions } from 'next-auth'; import KeycloakProvider from "next-auth/providers/keycloak";
import KeycloakProvider from 'next-auth/providers/keycloak'; import { NextAuthOptions } from "next-auth";
import { JWT } from 'next-auth/jwt'; import { JWT } from "next-auth/jwt";
declare module 'next-auth' { declare module "next-auth" {
interface Session { interface Session {
accessToken?: string; accessToken: string;
roles?: string[]; idToken: string;
roles: string[];
error?: string; error?: string;
} }
} }
declare module 'next-auth/jwt' { declare module "next-auth/jwt" {
interface JWT { interface JWT {
accessToken?: string; accessToken: string;
refreshToken?: string; idToken: string;
accessTokenExpires?: number; refreshToken: string;
accessTokenExpiresAt: number;
refreshTokenExpiresAt: number;
roles: string[];
error?: string; error?: string;
roles?: string[];
} }
} }
function decode(token: string) { const NEVER_EXPIRES = 9999999999;
try {
const base64 = token.split('.')[1];
return JSON.parse(Buffer.from(base64, 'base64').toString('utf-8')); export function decode(token: string) {
try {
const base64 = token.split(".")[1];
return JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
} catch { } catch {
return null; return null;
} }
@@ -33,46 +37,62 @@ function decode(token: string) {
async function refreshAccessToken(token: JWT): Promise<JWT> { async function refreshAccessToken(token: JWT): Promise<JWT> {
try { try {
if (!token.refreshToken) { if (!token.refreshToken) {
throw new Error('No refresh token'); throw new Error("Missing refresh token");
} }
const response = await fetch( const response = await fetch(
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
{ {
method: 'POST', method: "POST",
cache: 'no-store',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', "Content-Type": "application/x-www-form-urlencoded",
}, },
body: new URLSearchParams({ body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!, client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
grant_type: 'refresh_token', grant_type: "refresh_token",
refresh_token: token.refreshToken, refresh_token: token.refreshToken,
}), }),
}, }
); );
const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error('Refresh failed'); throw new Error(`Refresh failed: ${response.status} ${JSON.stringify(data)}`);
} }
const refreshed = await response.json(); const access = decode(data.access_token);
const refresh = decode(data.refresh_token);
const decoded = decode(refreshed.access_token); const clientId = process.env.KEYCLOAK_CLIENT_ID!;
return { return {
...token, ...token,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token ?? token.refreshToken, accessToken: data.access_token!,
accessTokenExpires: decoded?.exp refreshToken: data.refresh_token!,
? decoded.exp * 1000 idToken: data.id_token!,
: Date.now() + 5 * 60 * 1000,
accessTokenExpiresAt: access.exp!,
refreshTokenExpiresAt: refresh.exp ?? NEVER_EXPIRES,
roles: [
...new Set([
...(access?.realm_access?.roles ?? []),
...(access?.resource_access?.[clientId]?.roles ?? []),
]),
],
error: undefined, error: undefined,
}; };
} catch {
} catch (err) {
console.error("[refreshAccessToken] failed:", err);
return { return {
...token, ...token,
error: 'RefreshAccessTokenError', error: "RefreshAccessTokenError",
}; };
} }
} }
@@ -83,67 +103,134 @@ export const authOptions: NextAuthOptions = {
clientId: process.env.KEYCLOAK_CLIENT_ID!, clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!, issuer: process.env.KEYCLOAK_ISSUER!,
}), authorization: {
params: {
scope: "openid offline_access",
},
},
})
], ],
secret: process.env.NEXTAUTH_SECRET,
callbacks: { callbacks: {
async jwt({ token, account }) { async jwt({ token, account }) {
if (token.error === 'RefreshAccessTokenError') return token; const now = Math.floor(Date.now() / 1000);
// initial login
if (account) {
const decoded = decode(account.access_token!);
// 1. LOGIN
if (account) {
console.log("[jwt] LOGIN - building initial token state");
const decodedRefreshToken = decode(account.refresh_token!);
const decodedAccessToken = decode(account.access_token!);
const clientId = process.env.KEYCLOAK_CLIENT_ID!; const clientId = process.env.KEYCLOAK_CLIENT_ID!;
const realmRoles = decoded?.realm_access?.roles ?? []; token.accessToken = account.access_token!;
const clientRoles = decoded?.resource_access?.[clientId]?.roles ?? []; token.refreshToken = account.refresh_token!;
token.idToken = account.id_token!;
token.roles = [...new Set([...realmRoles, ...clientRoles])]; token.accessTokenExpiresAt = account.expires_at!;
token.sub = decodedAccessToken.sub!
token.accessToken = account.access_token; token.refreshTokenExpiresAt = decodedRefreshToken.exp ?? NEVER_EXPIRES;
token.refreshToken = account.refresh_token; token.roles = [
token.accessTokenExpires = decoded?.exp ...new Set([
? decoded.exp * 1000 ...(decodedAccessToken?.realm_access?.roles ?? []),
: Date.now() + 5 * 60 * 1000; ...(decodedAccessToken?.resource_access?.[clientId]?.roles ?? []),
]),
];
token.error = undefined;
return token; return token;
} }
// stop loop if refresh already failed // 2. SESSION EXPIRED → FORCE LOGIN
if ( if (now >= token.refreshTokenExpiresAt) {
token.accessTokenExpires && return { ...token, error: "RefreshTokenExpired" };
Date.now() > token.accessTokenExpires - 60_000
) {
return { ...token, error: 'RefreshAccessTokenError' };
} }
// still valid // 3. ACCESS TOKEN STILL VALID
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) { if (now < token.accessTokenExpiresAt - 60) {
return token; return token;
} }
// refresh expired token
return await refreshAccessToken(token); return await refreshAccessToken(token);
}, },
async session({ session, token }) { async session({ session, token }) {
session.accessToken = token.accessToken; session.accessToken = token.accessToken;
session.idToken = token.idToken;
session.roles = token.roles ?? [];
session.error = token.error; session.error = token.error;
if (!token.accessToken) {
session.roles = [];
return session;
}
const payload = decode(token.accessToken);
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
const realmRoles = payload?.realm_access?.roles ?? [];
const clientRoles = payload?.resource_access?.[clientId]?.roles ?? [];
session.roles = [...new Set([...realmRoles, ...clientRoles])];
return session; return session;
}, },
}, },
}; };
// {
// provider: 'keycloak',
// type: 'oauth',
// providerAccountId: '68ae60bf-277f-44b6-87a7-46e34072b9f4',
// access_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTY5MDIsImlhdCI6MTc3NzkxNjc4MiwiYXV0aF90aW1lIjoxNzc3OTEzNDc1LCJqdGkiOiJiZmRmMjc1ZC1jNWQ3LTRlZWQtYWJlYS04NDQ5YWRjY2E2NGMiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6WyJnaXRlYSIsImplbGx5ZmluIiwiYWNjb3VudCJdLCJzdWIiOiI2OGFlNjBiZi0yNzdmLTQ0YjYtODdhNy00NmUzNDA3MmI5ZjQiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJjaGF0LXdlYnNpdGUiLCJzZXNzaW9uX3N0YXRlIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2IiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWZhdWx0LXJvbGVzLWljZWJlcmciXX0sInJlc291cmNlX2FjY2VzcyI6eyJnaXRlYSI6eyJyb2xlcyI6WyJ0ZW1wbGF0ZSIsImljZWJlcmciLCJhZG1pbiIsImRlbGV0YW5nIiwidXNlciJdfSwiY2hhdC13ZWJzaXRlIjp7InJvbGVzIjpbImFkbWluIiwidXNlciJdfSwiamVsbHlmaW4iOnsicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIiwic2lkIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.X5PMv6-H21hdWvP768X4NDtRc_f5E1NALM1mSxM44-S8B8pQkP3ROSKZR2jmf828LOlLVfC-moODHTBgPkrwGGfkDpDXOtBzCo9-w_hu2S63OSSSg-ej3EPkT_7OocykSeq0V9VWllrdll7T6PiVuNX4dVstizVCweGJ7gtZXjDdi15VExfSjiddWk6mF-ZFX9B3TJFqjLfoXEaaEUrZJoqErKWbr8JlIYFPeaekYWxdScB_yoDJUPkruDShHbk5tGATr6OfkX_lsVQ5oEDDPQ_EHQU5P_r4ILEbXoAk96yJMTJo2yQLEgaeAX3RxBGXRlNYs3rV6cHYCw67mg0Now',
// expires_at: 1777916902,
// refresh_expires_in: 1800,
// refresh_token: 'eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2Nzc0ZGFiYi05NjAxLTQyOTYtYTkwYi0yOWFjYjVlM2U3N2UifQ.eyJleHAiOjE3Nzc5MTg1ODIsImlhdCI6MTc3NzkxNjc4MiwianRpIjoiODU3ZTZlMmEtZjI1MS00NTI3LWJjMDktYmY3MTM4ODE1NzQyIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmljZWJlcmcuYmxhY2svcmVhbG1zL2ljZWJlcmciLCJhdWQiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsInN1YiI6IjY4YWU2MGJmLTI3N2YtNDRiNi04N2E3LTQ2ZTM0MDcyYjlmNCIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJjaGF0LXdlYnNpdGUiLCJzZXNzaW9uX3N0YXRlIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2Iiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSIsInNpZCI6IjJlMjRjYmY3LTE0MWItNDZlMC05NDdmLTlhZmNiZTFmNzViNiJ9.ydQzc6YWDdem_ubf4s9dw4Ys6hRnQSWBp8ZeweWZg7NqVCl7SvdxJ2XUgXNxMZ88cx5u_pE1bL6Kahsjvmq-wA',
// token_type: 'Bearer',
// id_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTY5MDIsImlhdCI6MTc3NzkxNjc4MiwiYXV0aF90aW1lIjoxNzc3OTEzNDc1LCJqdGkiOiJhYjllYTliYi1lODkyLTQzNTctYmQ4NS0xYjk4NDg5ODliM2MiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6ImNoYXQtd2Vic2l0ZSIsInN1YiI6IjY4YWU2MGJmLTI3N2YtNDRiNi04N2E3LTQ2ZTM0MDcyYjlmNCIsInR5cCI6IklEIiwiYXpwIjoiY2hhdC13ZWJzaXRlIiwic2Vzc2lvbl9zdGF0ZSI6IjJlMjRjYmY3LTE0MWItNDZlMC05NDdmLTlhZmNiZTFmNzViNiIsImF0X2hhc2giOiJFV1JkS0JYNmd0czZoNHFId0ViWG5nIiwic2lkIjoiMmUyNGNiZjctMTQxYi00NmUwLTk0N2YtOWFmY2JlMWY3NWI2IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.e2Jn0nbJ1qzNwnuSCAJMjHn6J3JOpJJ84ttne-XiZqtUln9KD3OaPikXH0Rzl-bKy6WxW7ZOLNdEiRUZgliUXxnc3j8zT5Fincm6cEZ3FOXIozlzdoo5i59gjZ9QqvbQ0794RvQJ-24J4C8xMdn5g5baOehf3gJL4pTRYfCECgijbQ-oAnu04YLT3OZN9GpCreU4VoIfgdebX2eiDuyTyECnfpfJuDBeAyn19fNp5TzY2GQHUUbhW2CxiTvHSBhYSw5Zld_csgTmTisWVJ7wTbAM_A7xjn9VjxCs5UnexzufGWL_O02Pi2XdTbloSSbHcB7rMAtSNE-o0fBHjdaH2g',
// 'not-before-policy': 0,
// session_state: '2e24cbf7-141b-46e0-947f-9afcbe1f75b6',
// scope: 'openid profile'
// }
// REFRESH
// {
// exp: 1777969679,
// iat: 1777967879,
// jti: 'cfb68e49-9383-46b7-af35-ad8cd357cd55',
// iss: 'https://auth.iceberg.black/realms/iceberg',
// aud: 'https://auth.iceberg.black/realms/iceberg',
// sub: '68ae60bf-277f-44b6-87a7-46e34072b9f4',
// typ: 'Refresh',
// azp: 'chat-website',
// session_state: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1',
// scope: 'openid profile',
// sid: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1'
// }
// ACCESS
// {
// exp: 1777968408,
// iat: 1777968288,
// auth_time: 1777967879,
// jti: 'ef46047d-c911-427d-bd41-872c92b0ffd3',
// iss: 'https://auth.iceberg.black/realms/iceberg',
// aud: [ 'gitea', 'jellyfin', 'account' ],
// sub: '68ae60bf-277f-44b6-87a7-46e34072b9f4',
// typ: 'Bearer',
// azp: 'chat-website',
// session_state: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1',
// realm_access: {
// roles: [ 'offline_access', 'uma_authorization', 'default-roles-iceberg' ]
// },
// resource_access: {
// gitea: { roles: [Array] },
// 'chat-website': { roles: [Array] },
// jellyfin: { roles: [Array] },
// account: { roles: [Array] }
// },
// scope: 'openid profile',
// sid: '89ddf63b-401b-4bc7-a0e6-30b25a78b1f1',
// name: ' ',
// preferred_username: '',
// given_name: '',
// family_name: ''
// }
// TOKEN OF REFRESH
// {
// access_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTE1OTEsImlhdCI6MTc3NzkxMTQ3MSwiYXV0aF90aW1lIjoxNzc3OTA5MjAwLCJqdGkiOiJmNTI4ZWEzOC0zOTM0LTQ5ZTEtOGI0ZC1lNjU4OWY5ZTJjZDkiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6WyJnaXRlYSIsImplbGx5ZmluIiwiYWNjb3VudCJdLCJzdWIiOiI2OGFlNjBiZi0yNzdmLTQ0YjYtODdhNy00NmUzNDA3MmI5ZjQiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJjaGF0LXdlYnNpdGUiLCJzZXNzaW9uX3N0YXRlIjoiNjhhYThkMTItNGUwZS00N2M2LTg5NWYtMzM4NzA3MTE1MmY0IiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWZhdWx0LXJvbGVzLWljZWJlcmciXX0sInJlc291cmNlX2FjY2VzcyI6eyJnaXRlYSI6eyJyb2xlcyI6WyJ0ZW1wbGF0ZSIsImljZWJlcmciLCJhZG1pbiIsImRlbGV0YW5nIiwidXNlciJdfSwiY2hhdC13ZWJzaXRlIjp7InJvbGVzIjpbImFkbWluIiwidXNlciJdfSwiamVsbHlmaW4iOnsicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIG9mZmxpbmVfYWNjZXNzIiwic2lkIjoiNjhhYThkMTItNGUwZS00N2M2LTg5NWYtMzM4NzA3MTE1MmY0IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.NOol8mmBqxsvKwUFkWDzJE2GxmjU5DsIc-WX9BbNycFJtYcpcKLDeZfNMKlKid-JYnOd8pBt3phl_2OJtkYZPD_GVdq1QFfHkDCf7zEJPEf11lJd7pjT2S-adNzppentz2EJyW-Eb3m2Bqoeq0gaJbMi3gaEzfwgQWFTXxoNi5N7RHGVLoa9xtS4tU0p0IXKUOD4XV2aScSedv0qtTLog2x54ZyXw_mcH6UQNrNYU27Gf1fby_i2fgU-ZyD3CKPMD-kPlq2dwePVxh-z6tDGAOfwNw2gsHgScZMyQ4pO7UIkEs0IuxW-O-96FgRMhttKWE4F3sqcMbeQagw4SKs_Bg',
// expires_in: 120,
// refresh_expires_in: 0,
// refresh_token: 'eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2Nzc0ZGFiYi05NjAxLTQyOTYtYTkwYi0yOWFjYjVlM2U3N2UifQ.eyJpYXQiOjE3Nzc5MTE0NzEsImp0aSI6ImE5MzQ2ZWJhLTk0NTMtNDZmZS1iMzk2LTIxMDFkYjM3YjA3MyIsImlzcyI6Imh0dHBzOi8vYXV0aC5pY2ViZXJnLmJsYWNrL3JlYWxtcy9pY2ViZXJnIiwiYXVkIjoiaHR0cHM6Ly9hdXRoLmljZWJlcmcuYmxhY2svcmVhbG1zL2ljZWJlcmciLCJzdWIiOiI2OGFlNjBiZi0yNzdmLTQ0YjYtODdhNy00NmUzNDA3MmI5ZjQiLCJ0eXAiOiJPZmZsaW5lIiwiYXpwIjoiY2hhdC13ZWJzaXRlIiwic2Vzc2lvbl9zdGF0ZSI6IjY4YWE4ZDEyLTRlMGUtNDdjNi04OTVmLTMzODcwNzExNTJmNCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgb2ZmbGluZV9hY2Nlc3MiLCJzaWQiOiI2OGFhOGQxMi00ZTBlLTQ3YzYtODk1Zi0zMzg3MDcxMTUyZjQifQ.zd83fs3rwAdO7KodcGRZoW-COaLISWmSfh3vK6NNQp3zB4mypI09lGieEoTO_XOUWzNgmtt9YmbEgGgXk-uW7w',
// token_type: 'Bearer',
// id_token: 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJublpLek04TkZHVmpWbGFPRXZpMUtFSTVHQWRwaGlsYjh3RHRLeG5JOENZIn0.eyJleHAiOjE3Nzc5MTE1OTEsImlhdCI6MTc3NzkxMTQ3MSwiYXV0aF90aW1lIjoxNzc3OTA5MjAwLCJqdGkiOiI3MGEzOTc1MS04NzJjLTRmMzUtODJjNC01OTI5ODFkN2Q2NzgiLCJpc3MiOiJodHRwczovL2F1dGguaWNlYmVyZy5ibGFjay9yZWFsbXMvaWNlYmVyZyIsImF1ZCI6ImNoYXQtd2Vic2l0ZSIsInN1YiI6IjY4YWU2MGJmLTI3N2YtNDRiNi04N2E3LTQ2ZTM0MDcyYjlmNCIsInR5cCI6IklEIiwiYXpwIjoiY2hhdC13ZWJzaXRlIiwic2Vzc2lvbl9zdGF0ZSI6IjY4YWE4ZDEyLTRlMGUtNDdjNi04OTVmLTMzODcwNzExNTJmNCIsImF0X2hhc2giOiI3OGcyYUFsNEdqVXFxS0loRmhDTDNRIiwic2lkIjoiNjhhYThkMTItNGUwZS00N2M2LTg5NWYtMzM4NzA3MTE1MmY0IiwibmFtZSI6Ikx1Y2FzIERlbGV0YW5nIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibHVjYXMiLCJnaXZlbl9uYW1lIjoiTHVjYXMiLCJmYW1pbHlfbmFtZSI6IkRlbGV0YW5nIn0.PdsFWknqvdNKv68SWg9hau9ekFWHdcyA1HRJvwS55H0-ijUlMR2iAhjQYqb44aboko90wtIF1LLIsd2JQMY3gTgcU0qAsf5LdPMX_zRrXoACMYR26rTWX64OmgBX9oWv2rSduGIngR8k6xuugXNBXSXiMwYbHeMSJ7H79a2tyhxqjmnuCxtnWfyQg8yJGIC9e92dDimM5gq9hvNbVQXesjWS3pGCnq0EhVw_90sTMOwk_4CUuPy_0ZgVUNJklyJzxYqMibYto_X2Mb2g2JT2gUZAym78URYhLiVg1A99vh9JTx4NC1vHktlIsEGZLySz52FvtVo7P-C3EqdLKMrSKw',
// 'not-before-policy': 0,
// session_state: '68aa8d12-4e0e-47c6-895f-3387071152f4',
// scope: 'openid profile offline_access'
// }
-1
View File
@@ -6,7 +6,6 @@ import { getToken } from 'next-auth/jwt';
const protectedRoutes: Record<string, string[]> = { const protectedRoutes: Record<string, string[]> = {
'/example': ['ad'], '/example': ['ad'],
'/dashboard': [],
}; };
const intlMiddleware = createIntlMiddleware(routing); const intlMiddleware = createIntlMiddleware(routing);