Compare commits

...

19 Commits

Author SHA1 Message Date
Nicolas Meienberger
3d87814aee ui: empty state 2025-10-04 14:43:29 +02:00
Nicolas Meienberger
56a4afdc92 chore: remove prismjs 2025-10-04 14:20:36 +02:00
Nicolas Meienberger
728cfebeb7 refactor: extract grid background 2025-10-04 14:02:34 +02:00
Nicolas Meienberger
472f7799a4 ui: redesign tabs 2025-10-04 13:26:34 +02:00
Nicolas Meienberger
e134d0e1d1 ui: redesign 2025-10-04 00:16:47 +02:00
Nicolas Meienberger
689f14dff7 ui: card corner accent 2025-10-03 23:11:14 +02:00
Nicolas Meienberger
8d46074bb1 fix: issue with the test connection message 2025-10-03 21:50:57 +02:00
Nicolas Meienberger
5f003fe69d fix: build issues 2025-10-03 20:19:54 +02:00
Nicolas Meienberger
7784389b57 docs: update README 2025-10-02 21:56:41 +02:00
Nicolas Meienberger
1ad8f69355 refactor: use rhf for login and onboarding 2025-10-02 21:52:55 +02:00
Nicolas Meienberger
2be7e18ab5 feat: auth client middleware 2025-10-02 21:28:08 +02:00
Nicolas Meienberger
0120641e3a feat: onboarding flow 2025-10-02 20:30:02 +02:00
Nicolas Meienberger
689e92ffc1 chore: small ui improvements 2025-10-02 19:59:04 +02:00
Nicolas Meienberger
86adda848e fix: mobile viewport 2025-10-02 19:34:08 +02:00
Nicolas Meienberger
c013351026 fix: make the add form scrollable for smaller screens 2025-10-02 18:47:53 +02:00
Nicolas Meienberger
1e7530cc09 feat: authentication 2025-10-02 18:47:25 +02:00
Nico
7f79fd7628 Update docker-compose command to use 'docker compose' 2025-10-01 21:52:13 +02:00
Nico
c29f35fc34 Update README.md 2025-10-01 21:50:09 +02:00
Nicolas Meienberger
9872185b69 docs: update README again 2025-10-01 21:43:57 +02:00
47 changed files with 2150 additions and 701 deletions

View File

@@ -1,6 +1,8 @@
*
!turbo.json
!bun.lock
!package.json
!**/package.json
!**/bun.lock
@@ -8,6 +10,7 @@
!**/vite.config.ts
!**/react-router.config.ts
!**/build.ts
!**/components.json
!apps/**/src/**
!apps/**/drizzle/**

View File

@@ -1,8 +1,8 @@
ARG BUN_VERSION="1.2.20"
ARG BUN_VERSION="1.2.23"
FROM oven/bun:${BUN_VERSION}-alpine AS runner_base
RUN apk add --no-cache davfs2
RUN apk add --no-cache davfs2=1.6.1-r2
# ------------------------------
# DEVELOPMENT
@@ -49,7 +49,9 @@ FROM runner_base AS production
ENV NODE_ENV="production"
RUN apk add --no-cache davfs2
# RUN bun i ssh2
RUN apk add --no-cache davfs2=1.6.1-r2
WORKDIR /app

View File

@@ -23,9 +23,7 @@ Ironmount is an easy to use web interface to manage your remote storage and moun
### Features
https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-creation.png?raw=true
- ✅  Support for multiple protocols: NFS, SMB, FTP, Directory
- ✅  Support for multiple protocols: NFS, SMB, WebDAV, Directory
- 📡  Mount your remote storage as local folders
- 🐳  Docker integration: mount your remote storage directly into your containers via a docker volume syntax
- 🔍  Keep an eye on your mounts with health checks and automatic remounting on error
@@ -33,11 +31,8 @@ https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-creation.png?r
### Coming soon
- 🔐  User authentication and role management
- 💾  Automated backups and snapshots with encryption, strategies and retention policies
- 🔄  Re-exporting your mounts to other protocols (e.g. mount an FTP server as an SMB share with fine-grained permissions)
- ☁️  Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox)
- 🔀  Storage sharding and replication for high availability and performance
- Automated backups with encryption and retention policies
- Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox)
## Installation
@@ -46,7 +41,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml
services:
ironmount:
image: nicotsx/ironmount:v0.0.1
image: ghcr.io/nicotsx/ironmount:v0.0.1
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -69,7 +64,7 @@ volumes:
Then, run the following command to start Ironmount:
```bash
docker-compose up -d
docker compose up -d
```
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.

View File

@@ -1,22 +0,0 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

View File

@@ -2,6 +2,11 @@
import {
type Options,
register,
login,
logout,
getMe,
getStatus,
listVolumes,
createVolume,
testConnection,
@@ -15,6 +20,14 @@ import {
} from "../sdk.gen";
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
import type {
RegisterData,
RegisterResponse,
LoginData,
LoginResponse,
LogoutData,
LogoutResponse,
GetMeData,
GetStatusData,
ListVolumesData,
CreateVolumeData,
CreateVolumeResponse,
@@ -74,6 +87,163 @@ const createQueryKey = <TOptions extends Options>(
return [params];
};
export const registerQueryKey = (options?: Options<RegisterData>) => createQueryKey("register", options);
/**
* Register a new user
*/
export const registerOptions = (options?: Options<RegisterData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await register({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: registerQueryKey(options),
});
};
/**
* Register a new user
*/
export const registerMutation = (
options?: Partial<Options<RegisterData>>,
): UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> => {
const mutationOptions: UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> = {
mutationFn: async (localOptions) => {
const { data } = await register({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const loginQueryKey = (options?: Options<LoginData>) => createQueryKey("login", options);
/**
* Login with username and password
*/
export const loginOptions = (options?: Options<LoginData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await login({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: loginQueryKey(options),
});
};
/**
* Login with username and password
*/
export const loginMutation = (
options?: Partial<Options<LoginData>>,
): UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> => {
const mutationOptions: UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> = {
mutationFn: async (localOptions) => {
const { data } = await login({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const logoutQueryKey = (options?: Options<LogoutData>) => createQueryKey("logout", options);
/**
* Logout current user
*/
export const logoutOptions = (options?: Options<LogoutData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await logout({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: logoutQueryKey(options),
});
};
/**
* Logout current user
*/
export const logoutMutation = (
options?: Partial<Options<LogoutData>>,
): UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> => {
const mutationOptions: UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> = {
mutationFn: async (localOptions) => {
const { data } = await logout({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const getMeQueryKey = (options?: Options<GetMeData>) => createQueryKey("getMe", options);
/**
* Get current authenticated user
*/
export const getMeOptions = (options?: Options<GetMeData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getMe({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getMeQueryKey(options),
});
};
export const getStatusQueryKey = (options?: Options<GetStatusData>) => createQueryKey("getStatus", options);
/**
* Get authentication system status
*/
export const getStatusOptions = (options?: Options<GetStatusData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getStatus({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getStatusQueryKey(options),
});
};
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
/**

View File

@@ -2,6 +2,19 @@
import type { Options as ClientOptions, TDataShape, Client } from "./client";
import type {
RegisterData,
RegisterResponses,
RegisterErrors,
LoginData,
LoginResponses,
LoginErrors,
LogoutData,
LogoutResponses,
GetMeData,
GetMeResponses,
GetMeErrors,
GetStatusData,
GetStatusResponses,
ListVolumesData,
ListVolumesResponses,
CreateVolumeData,
@@ -48,6 +61,64 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
meta?: Record<string, unknown>;
};
/**
* Register a new user
*/
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<RegisterResponses, RegisterErrors, ThrowOnError>({
url: "/api/v1/auth/register",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Login with username and password
*/
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<LoginResponses, LoginErrors, ThrowOnError>({
url: "/api/v1/auth/login",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Logout current user
*/
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<LogoutResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/logout",
...options,
});
};
/**
* Get current authenticated user
*/
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetMeResponses, GetMeErrors, ThrowOnError>({
url: "/api/v1/auth/me",
...options,
});
};
/**
* Get authentication system status
*/
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetStatusResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/status",
...options,
});
};
/**
* List all volumes
*/

View File

@@ -1,5 +1,134 @@
// This file is auto-generated by @hey-api/openapi-ts
export type RegisterData = {
body?: {
password: string;
username: string;
};
path?: never;
query?: never;
url: "/api/v1/auth/register";
};
export type RegisterErrors = {
/**
* Invalid request or username already exists
*/
400: unknown;
};
export type RegisterResponses = {
/**
* User created successfully
*/
201: {
message: string;
user: {
id: string;
username: string;
};
};
};
export type RegisterResponse = RegisterResponses[keyof RegisterResponses];
export type LoginData = {
body?: {
password: string;
username: string;
};
path?: never;
query?: never;
url: "/api/v1/auth/login";
};
export type LoginErrors = {
/**
* Invalid credentials
*/
401: unknown;
};
export type LoginResponses = {
/**
* Login successful
*/
200: {
message: string;
user: {
id: string;
username: string;
};
};
};
export type LoginResponse = LoginResponses[keyof LoginResponses];
export type LogoutData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/auth/logout";
};
export type LogoutResponses = {
/**
* Logout successful
*/
200: {
message: string;
};
};
export type LogoutResponse = LogoutResponses[keyof LogoutResponses];
export type GetMeData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/auth/me";
};
export type GetMeErrors = {
/**
* Not authenticated
*/
401: unknown;
};
export type GetMeResponses = {
/**
* Current user information
*/
200: {
message: string;
user: {
id: string;
username: string;
};
};
};
export type GetMeResponse = GetMeResponses[keyof GetMeResponses];
export type GetStatusData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/auth/status";
};
export type GetStatusResponses = {
/**
* Authentication system status
*/
200: {
hasUsers: boolean;
};
};
export type GetStatusResponse = GetStatusResponses[keyof GetStatusResponses];
export type ListVolumesData = {
body?: never;
path?: never;

View File

@@ -11,7 +11,10 @@
html,
body {
@apply bg-white dark:bg-[#0D0D0D];
@apply bg-white dark:bg-[#131313];
overflow-x: hidden;
width: 100%;
position: relative;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
@@ -27,6 +30,7 @@ body {
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-card-header: var(--card-header);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
@@ -54,6 +58,7 @@ body {
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-strong-accent: var(--strong-accent);
}
:root {
@@ -62,6 +67,7 @@ body {
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-header: oklch(0.922 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
@@ -89,12 +95,14 @@ body {
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--strong-accent: #ff543a;
}
.dark {
--background: oklch(0.145 0 0);
--background: #131313;
--foreground: oklch(0.985 0 0);
--card: oklch(0.1448 0 0);
--card: #131313;
--card-header: #1b1b1b;
/* --card: oklch(0.205 0 0); ORIGINAL */
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
@@ -108,7 +116,7 @@ body {
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive: #ff543a;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
@@ -125,6 +133,7 @@ body {
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--strong-accent: #ff543a;
}
@layer base {

View File

@@ -14,14 +14,20 @@ export function AppBreadcrumb() {
const breadcrumbs = useBreadcrumbs();
return (
<Breadcrumb className={cn("mb-2", { invisible: breadcrumbs.length <= 1 })}>
<Breadcrumb>
<BreadcrumbLink asChild></BreadcrumbLink>
<BreadcrumbList>
{breadcrumbs.length === 1 && (
<BreadcrumbItem>
<Link to="/">Ironmount</Link>
</BreadcrumbItem>
)}
{breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<div key={`${breadcrumb.label}-${index}`} className="contents">
<BreadcrumbItem>
<BreadcrumbItem className={cn({ invisible: breadcrumbs.length <= 1 })}>
{isLast || breadcrumb.isCurrentPage ? (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
) : breadcrumb.href ? (

View File

@@ -6,15 +6,8 @@ import { createVolumeMutation } from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors";
import { CreateVolumeForm } from "./create-volume-form";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
type Props = {
open: boolean;
@@ -40,31 +33,33 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-blue-900 hover:bg-blue-800">
<Button>
<Plus size={16} className="mr-2" />
Create volume
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create volume</DialogTitle>
<DialogDescription>Enter a name for the new volume</DialogDescription>
</DialogHeader>
<CreateVolumeForm
mode="create"
formId={formId}
onSubmit={(values) => {
create.mutate({ body: { config: values, name: values.name } });
}}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={create.isPending}>
Create
</Button>
</DialogFooter>
<ScrollArea className="h-[500px]">
<DialogHeader>
<DialogTitle>Create volume</DialogTitle>
</DialogHeader>
<CreateVolumeForm
className="mt-4"
mode="create"
formId={formId}
onSubmit={(values) => {
create.mutate({ body: { config: values, name: values.name } });
}}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={create.isPending}>
Create
</Button>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
);

View File

@@ -6,7 +6,7 @@ import { CheckCircle, Loader2, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
import { slugify } from "~/lib/utils";
import { cn, slugify } from "~/lib/utils";
import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
@@ -24,6 +24,7 @@ type Props = {
initialValues?: Partial<FormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
@@ -33,7 +34,7 @@ const defaultValuesForType = {
webdav: { backend: "webdav" as const, port: 80, ssl: false },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading }: Props) => {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({
resolver: arktypeResolver(formSchema),
defaultValues: initialValues,
@@ -52,22 +53,21 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
}, [watchedBackend, watchedName, form.reset]);
const [testMessage, setTestMessage] = useState<string>("");
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
const testBackendConnection = useMutation({
...testConnectionMutation(),
onMutate: () => {
setTestMessage("");
setTestMessage(null);
},
onError: () => {
setTestMessage("Failed to test connection. Please try again.");
onError: (error) => {
setTestMessage({
success: false,
message: error?.message || "Failed to test connection. Please try again.",
});
},
onSuccess: (data) => {
if (data?.success) {
setTestMessage(data.message);
} else {
setTestMessage(data?.message || "Connection test failed");
}
setTestMessage(data);
},
});
@@ -83,7 +83,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
@@ -423,112 +423,45 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</>
)}
{watchedBackend === "smb" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
</Button>
</div>
{testMessage && (
<div
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
</div>
)}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{!testBackendConnection.isPending && testMessage?.success && (
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
)}
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
<XCircle className="mr-2 h-4 w-4 text-red-500" />
)}
{testBackendConnection.isPending
? "Testing..."
: testMessage
? testMessage.success
? "Connection Successful"
: "Test Failed"
: "Test Connection"}
</Button>
</div>
)}
{watchedBackend === "nfs" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
</Button>
{testMessage && (
<div
className={`text-xs p-2 rounded-md ${
testMessage.success
? "bg-green-50 text-green-700 border border-green-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}
>
{testMessage.message}
</div>
{testMessage && (
<div
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
</div>
)}
</div>
)}
{watchedBackend === "webdav" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
</Button>
</div>
{testMessage && (
<div
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
</div>
)}
</div>
)}
)}
</div>
{mode === "update" && (
<Button type="submit" className="w-full mt-4" loading={loading}>
<Button type="submit" className="w-full" loading={loading}>
Save Changes
</Button>
)}

View File

@@ -0,0 +1,56 @@
import { Database, HardDrive, HeartPulse, Plus } from "lucide-react";
import { CreateVolumeDialog } from "./create-volume-dialog";
import { useState } from "react";
export function EmptyState() {
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3 mb-8">
<h3 className="text-2xl font-semibold text-foreground">No volumes yet</h3>
<p className="text-muted-foreground">
Get started by creating your first volume. Manage and monitor all your storage backends in one place with
advanced features like automatic mounting and health checks.
</p>
</div>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-0 max-w-3xl">
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Database className="w-5 h-5 text-primary" />
</div>
<h4 className="font-medium text-sm">Multiple Backends</h4>
<p className="text-xs text-muted-foreground">Support for local, NFS, and SMB storage</p>
</div>
<div className="flex flex-col items-center gap-2 p-4 border border-r-0 border-l-0 bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<HardDrive className="w-5 h-5 text-primary" />
</div>
<h4 className="font-medium text-sm">Auto Mounting</h4>
<p className="text-xs text-muted-foreground">Automatic lifecycle management</p>
</div>
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<HeartPulse className="w-5 h-5 text-primary" />
</div>
<h4 className="font-medium text-sm">Real-time Monitoring</h4>
<p className="text-xs text-muted-foreground">Live status and health checks</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
interface GridBackgroundProps {
children: ReactNode;
className?: string;
containerClassName?: string;
}
export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) {
return (
<div
className={cn(
"relative min-h-dvh w-full overflow-x-hidden",
"[background-size:20px_20px] sm:[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
containerClassName,
)}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-card" />
<div className={cn("relative h-screen", className)}>{children}</div>
</div>
);
}

View File

@@ -1,22 +1,55 @@
import { Outlet } from "react-router";
import { cn } from "~/lib/utils";
import { useMutation } from "@tanstack/react-query";
import { Outlet, useNavigate } from "react-router";
import { toast } from "sonner";
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
import { appContext } from "~/context";
import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/layout";
import { AppBreadcrumb } from "./app-breadcrumb";
import { GridBackground } from "./grid-background";
import { Button } from "./ui/button";
export const clientMiddleware = [authMiddleware];
export async function clientLoader({ context }: Route.LoaderArgs) {
const ctx = context.get(appContext);
return ctx;
}
export default function Layout({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const logout = useMutation({
...logoutMutation(),
onSuccess: async () => {
navigate("/login", { replace: true });
},
onError: (error) => {
console.error(error);
toast.error("Logout failed");
},
});
export default function Layout() {
return (
<div
className={cn(
"relative min-h-dvh w-full",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
)}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-black"></div>
<main className="relative flex flex-col pt-8 p-4 container mx-auto">
<AppBreadcrumb />
<GridBackground>
<header className="bg-card-header border-b border-border/50">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-4 container mx-auto">
<AppBreadcrumb />
{loaderData.user && (
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
</span>
<Button variant="outline" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
Logout
</Button>
</div>
)}
</div>
</header>
<main className="flex flex-col pt-4 sm:pt-8 px-2 sm:px-4 pb-4 container mx-auto">
<Outlet />
</main>
</div>
</GridBackground>
);
}

View File

@@ -6,23 +6,23 @@ import type * as React from "react";
import { cn } from "~/lib/utils";
const buttonVariants = cva(
"inline-flex cursor-pointer uppercase border items-center justify-center dark:border-white dark:bg-secondary dark:text-white gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "rounded-xs h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 px-6 has-[>svg]:px-4",
default: "h-9 px-5 py-2 has-[>svg]:px-4",
sm: "h-8 px-3 py-1.5 has-[>svg]:px-2.5",
lg: "h-10 px-6 py-2.5 has-[>svg]:px-5",
icon: "size-9",
},
},

View File

@@ -1,17 +1,26 @@
import * as React from "react";
import type * as React from "react";
import { cn } from "~/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
function Card({ className, children, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 border-2 py-6 shadow-sm",
className,
)}
className={cn("bg-card text-card-foreground relative flex flex-col gap-6 border-2 py-6 shadow-sm", className)}
{...props}
/>
>
<span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none">
<span className="absolute left-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
</span>
{children}
</div>
);
}
@@ -29,64 +38,31 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View File

@@ -1,7 +1,4 @@
import React, { useEffect } from "react";
import Prism from "prismjs";
import "prismjs/themes/prism-twilight.css";
import "prismjs/components/prism-yaml";
import type React from "react";
import { toast } from "sonner";
import { copyToClipboard } from "~/utils/clipboard";
@@ -11,35 +8,31 @@ interface CodeBlockProps {
filename?: string;
}
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language = "jsx", filename }) => {
useEffect(() => {
Prism.highlightAll();
}, []);
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
const handleCopy = async () => {
await copyToClipboard(code);
toast.success("Code copied to clipboard");
};
return (
<div className="overflow-hidden rounded-sm bg-slate-900 ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs text-slate-400">
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
{filename && <span className="ml-3 font-medium text-slate-300">{filename}</span>}
{filename && <span className="ml-3 font-medium">{filename}</span>}
</div>
<button
type="button"
onClick={() => handleCopy()}
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium text-slate-300 ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
>
Copy
</button>
</div>
<pre className="overflow-x-auto leading-6 text-xs m-0" style={{ marginTop: 0, marginBottom: 0 }}>
<code className={`language-${language}`}>{code}</code>
<pre className="text-xs m-0 px-4 py-2 bg-card-header">
<code className="text-white/80">{code}</code>
</pre>
</div>
);

View File

@@ -1,4 +1,4 @@
import * as React from "react";
import type * as React from "react";
import { cn } from "~/lib/utils";
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "~/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -1,64 +1,58 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn("inline-flex h-7 items-center gap-4 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"cursor-pointer group relative inline-flex h-7 items-center whitespace-nowrap text-xs font-medium transition-colors",
"text-muted-foreground data-[state=active]:text-foreground disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
"px-5",
// Transparent orange background for active state
"data-[state=active]:bg-[#FF453A]/10",
// Left bracket - vertical line
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
// Left bracket - top tick
"after:absolute after:left-0 after:top-[-1px] after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
className,
)}
{...props}
>
<span className="relative z-10">{props.children}</span>
{/* Left bracket - bottom tick */}
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - top tick */}
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - vertical line */}
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - bottom tick */}
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
</TabsPrimitive.Trigger>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -42,10 +42,10 @@ const getIconAndColor = (backend: BackendType) => {
};
export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => {
const { icon: Icon, color, label } = getIconAndColor(backend);
const { icon: Icon, label } = getIconAndColor(backend);
return (
<span className={`flex items-center gap-2 ${color} rounded-md px-2 py-1`}>
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
<Icon size={size} />
{label}
</span>

View File

@@ -0,0 +1,12 @@
import { createContext } from "react-router";
import type { User } from "./lib/types";
type AppContext = {
user: User | null;
hasUsers: boolean;
};
export const appContext = createContext<AppContext>({
user: null,
hasUsers: false,
});

View File

@@ -1,5 +1,7 @@
import type { GetVolumeResponse } from "~/api-client";
import type { GetMeResponse, GetVolumeResponse } from "~/api-client";
export type Volume = GetVolumeResponse["volume"];
export type StatFs = GetVolumeResponse["statfs"];
export type VolumeStatus = Volume["status"];
export type User = GetMeResponse["user"];

View File

@@ -0,0 +1,18 @@
import { redirect, type MiddlewareFunction } from "react-router";
import { getMe, getStatus } from "~/api-client";
import { appContext } from "~/context";
export const authMiddleware: MiddlewareFunction = async ({ context }) => {
const session = await getMe();
if (!session.data?.user.id) {
const status = await getStatus();
if (!status.data?.hasUsers) {
throw redirect("/onboarding");
}
throw redirect("/login");
}
context.set(appContext, { user: session.data.user, hasUsers: true });
};

View File

@@ -18,7 +18,7 @@ export function StorageChart({ statfs }: Props) {
{
name: "Used",
value: statfs.used,
fill: "#2B7EFF",
fill: "#ff543a",
},
{
name: "Free",
@@ -63,7 +63,7 @@ export function StorageChart({ statfs }: Props) {
</CardTitle>
</CardHeader>
<CardContent className="flex-1 pb-0">
<div className="">
<div>
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
<PieChart>
<ChartTooltip
@@ -105,9 +105,9 @@ export function StorageChart({ statfs }: Props) {
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-blue-500/10">
<div className="flex items-center justify-between p-3 rounded-lg bg-strong-accent/10">
<div className="flex items-center gap-3">
<div className="h-4 w-4 rounded-full bg-blue-500" />
<div className="h-4 w-4 rounded-full bg-strong-accent" />
<span className="font-medium">Used Space</span>
</div>
<div className="text-right">

View File

@@ -40,7 +40,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<html lang="en" style={{ colorScheme: "dark" }} className="dark">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<Meta />
<Links />
</head>

View File

@@ -1,5 +1,7 @@
import { index, layout, type RouteConfig, route } from "@react-router/dev/routes";
export default [
route("onboarding", "./routes/onboarding.tsx"),
route("login", "./routes/login.tsx"),
layout("./components/layout.tsx", [index("./routes/home.tsx"), route("volumes/:name", "./routes/details.tsx")]),
] satisfies RouteConfig;

View File

@@ -111,7 +111,6 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
</div>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => mountVol.mutate({ path: { name } })}
loading={mountVol.isPending}
className={cn({ hidden: volume.status === "mounted" })}
@@ -131,8 +130,8 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
</Button>
</div>
</div>
<Tabs defaultValue="info" className="mt-0">
<TabsList>
<Tabs defaultValue="info" className="mt-4">
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>

View File

@@ -5,11 +5,13 @@ import { useNavigate } from "react-router";
import { listVolumes } from "~/api-client";
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
import { CreateVolumeDialog } from "~/components/create-volume-dialog";
import { EmptyState } from "~/components/empty-state";
import { StatusDot } from "~/components/status-dot";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { VolumeIcon } from "~/components/volume-icon";
import type { Route } from "./+types/home";
@@ -58,22 +60,29 @@ export default function Home({ loaderData }: Route.ComponentProps) {
return matchesSearch && matchesStatus && matchesBackend;
}) || [];
const hasNoVolumes = data?.volumes.length === 0;
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
if (hasNoVolumes) {
return (
<Card className="p-0 gap-0">
<EmptyState />
</Card>
);
}
return (
<>
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
<h2 className="text-sm font-semibold mb-2 text-muted-foreground">
Create, manage, monitor, and automate your volumes with ease.
</h2>
<div className="flex items-center gap-2 mt-4 justify-between">
<span className="flex items-center gap-2">
<Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
<Input
className="w-[180px]"
className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"
placeholder="Search volumes…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]">
<SelectValue placeholder="All status" />
</SelectTrigger>
<SelectContent>
@@ -83,7 +92,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</SelectContent>
</Select>
<Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]">
<SelectValue placeholder="All backends" />
</SelectTrigger>
<SelectContent>
@@ -93,7 +102,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</SelectContent>
</Select>
{(searchQuery || statusFilter || backendFilter) && (
<Button variant="outline" size="sm" onClick={clearFilters}>
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
@@ -101,42 +110,67 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</span>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
</div>
<Table className="mt-4 border bg-white dark:bg-secondary">
<TableCaption>A list of your managed volumes.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase">Mountpoint</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredVolumes.map((volume) => (
<TableRow
key={volume.name}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/volumes/${volume.name}`)}
>
<TableCell className="font-medium">{volume.name}</TableCell>
<TableCell>
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
{volume.path}
</span>
<Copy size={10} />
</span>
</TableCell>
<TableCell className="text-center">
<StatusDot status={volume.status} />
</TableCell>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase hidden sm:table-cell">Mountpoint</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</>
</TableHeader>
<TableBody>
{hasNoFilteredVolumes ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No volumes match your filters.</p>
<Button onClick={clearFilters} variant="outline" size="sm">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
</div>
</TableCell>
</TableRow>
) : (
filteredVolumes.map((volume) => (
<TableRow
key={volume.name}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/volumes/${volume.name}`)}
>
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
<TableCell>
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
{volume.path}
</span>
<Copy size={10} />
</span>
</TableCell>
<TableCell className="text-center">
<StatusDot status={volume.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
{hasNoFilteredVolumes ? (
"No volumes match filters."
) : (
<span>
<span className="text-strong-accent">{filteredVolumes.length}</span> volume
{filteredVolumes.length > 1 ? "s" : ""}
</span>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,103 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { loginMutation } from "~/api-client/@tanstack/react-query.gen";
import { GridBackground } from "~/components/grid-background";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
const loginSchema = type({
username: "2<=string<=50",
password: "string>=1",
});
type LoginFormValues = typeof loginSchema.inferIn;
export default function LoginPage() {
const navigate = useNavigate();
const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
});
const login = useMutation({
...loginMutation(),
onSuccess: async () => {
navigate("/");
},
onError: (error) => {
console.error(error);
toast.error("Login failed");
},
});
const onSubmit = (values: LoginFormValues) => {
login.mutate({
body: {
username: values.username.trim(),
password: values.password.trim(),
},
});
};
return (
<GridBackground className="flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
<CardDescription>Sign in to your account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
{...field}
type="text"
placeholder="Enter your username"
disabled={login.isPending}
autoFocus
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="Enter your password" disabled={login.isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={login.isPending}>
Sign In
</Button>
</form>
</Form>
</CardContent>
</Card>
</GridBackground>
);
}

View File

@@ -0,0 +1,133 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { registerMutation } from "~/api-client/@tanstack/react-query.gen";
import { GridBackground } from "~/components/grid-background";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
const onboardingSchema = type({
username: "2<=string<=50",
password: "string>=8",
confirmPassword: "string>=1",
});
type OnboardingFormValues = typeof onboardingSchema.inferIn;
export default function OnboardingPage() {
const navigate = useNavigate();
const form = useForm<OnboardingFormValues>({
resolver: arktypeResolver(onboardingSchema),
defaultValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const registerUser = useMutation({
...registerMutation(),
onSuccess: async () => {
toast.success("Admin user created successfully!");
navigate("/");
},
onError: (error) => {
console.error(error);
toast.error("Failed to create admin user");
},
});
const onSubmit = (values: OnboardingFormValues) => {
if (values.password !== values.confirmPassword) {
form.setError("confirmPassword", {
type: "manual",
message: "Passwords do not match",
});
return;
}
registerUser.mutate({
body: {
username: values.username.trim(),
password: values.password.trim(),
},
});
};
return (
<GridBackground className="flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl font-bold">Welcome to Ironmount</CardTitle>
<CardDescription>Create the admin user to get started</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus />
</FormControl>
<FormDescription>Choose a username for the admin account</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="Enter a secure password"
disabled={registerUser.isPending}
/>
</FormControl>
<FormDescription>Password must be at least 8 characters long.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="Re-enter your password"
disabled={registerUser.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={registerUser.isPending}>
Create Admin User
</Button>
</form>
</Form>
</CardContent>
</Card>
</GridBackground>
);
}

View File

@@ -9,49 +9,50 @@
"tsc": "react-router typegen && tsc"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@hookform/resolvers": "^5.2.2",
"@ironmount/schemas": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"@tanstack/react-query": "^5.84.2",
"@tanstack/react-query-devtools": "^5.85.9",
"@react-router/node": "^7.9.3",
"@react-router/serve": "^7.9.3",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
"arktype": "^2.1.20",
"arktype": "^2.1.22",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"isbot": "^5.1.27",
"isbot": "^5.1.31",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.62.0",
"react-router": "^7.7.1",
"recharts": "2.15.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.63.0",
"react-router": "^7.9.3",
"recharts": "3.2.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"yaml": "^2.8.1"
},
"devDependencies": {
"@react-router/dev": "^7.7.1",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^20",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4",
"tw-animate-css": "^1.3.6",
"typescript": "^5.8.3",
"vite": "npm:rolldown-vite@latest",
"@react-router/dev": "^7.9.3",
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^24.6.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"lightningcss": "^1.30.2",
"tailwindcss": "^4.1.14",
"tinyglobby": "^0.2.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.1.9",
"vite-bundle-analyzer": "^1.2.3",
"vite-tsconfig-paths": "^5.1.4"
}

View File

@@ -3,4 +3,7 @@ import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
buildDirectory: "dist",
future: {
v8_middleware: true,
},
} satisfies Config;

View File

@@ -9,5 +9,5 @@ await Bun.build({
identifiers: true,
syntax: true,
},
external: [],
external: ["ssh2"],
});

View File

@@ -0,0 +1,17 @@
CREATE TABLE `sessions_table` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);

View File

@@ -0,0 +1,226 @@
{
"version": "6",
"dialect": "sqlite",
"id": "75f0aac0-aa63-4577-bfb6-4638a008935f",
"prevId": "0b087a68-fbc6-4647-a6dc-e6322a3d4ee3",
"tables": {
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1758961535488,
"tag": "0004_wealthy_tomas",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1759416698274,
"tag": "0005_simple_alice",
"breakpoints": true
}
]
}

View File

@@ -5,7 +5,8 @@
"dev": "bun run --watch src/index.ts",
"build": "rm -rf dist && bun build.ts",
"tsc": "tsc --noEmit",
"gen:migrations": "drizzle-kit generate"
"gen:migrations": "drizzle-kit generate",
"studio": "drizzle-kit studio"
},
"dependencies": {
"@hono/arktype-validator": "^2.0.1",
@@ -15,7 +16,7 @@
"arktype": "^2.1.20",
"dockerode": "^4.0.8",
"dotenv": "^17.2.1",
"drizzle-orm": "^0.44.4",
"drizzle-orm": "^0.44.6",
"hono": "^4.9.2",
"hono-openapi": "^1.1.0",
"http-errors-enhanced": "^3.0.2",
@@ -24,8 +25,9 @@
"winston": "^3.17.0"
},
"devDependencies": {
"@libsql/client": "^0.15.15",
"@types/bun": "^1.2.20",
"@types/dockerode": "^3.3.44",
"drizzle-kit": "^0.31.4"
"drizzle-kit": "^0.31.5"
}
}

View File

@@ -3,9 +3,11 @@ import "dotenv/config";
const envSchema = type({
NODE_ENV: type.enumerated("development", "production", "test").default("development"),
SESSION_SECRET: "string?",
}).pipe((s) => ({
__prod__: s.NODE_ENV === "production",
environment: s.NODE_ENV,
sessionSecret: s.SESSION_SECRET || "change-me-in-production-please",
}));
const parseConfig = (env: unknown) => {

View File

@@ -17,3 +17,24 @@ export const volumesTable = sqliteTable("volumes_table", {
});
export type Volume = typeof volumesTable.$inferSelect;
export const usersTable = sqliteTable("users_table", {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
});
export type User = typeof usersTable.$inferSelect;
export const sessionsTable = sqliteTable("sessions_table", {
id: text().primaryKey(),
userId: int("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "timestamp" }).notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
});
export type Session = typeof sessionsTable.$inferSelect;

View File

@@ -5,6 +5,8 @@ import { serveStatic } from "hono/bun";
import { logger as honoLogger } from "hono/logger";
import { openAPIRouteHandler } from "hono-openapi";
import { runDbMigrations } from "./db/db";
import { authController } from "./modules/auth/auth.controller";
import { requireAuth } from "./modules/auth/auth.middleware";
import { driverController } from "./modules/driver/driver.controller";
import { startup } from "./modules/lifecycle/startup";
import { volumeController } from "./modules/volumes/volume.controller";
@@ -32,13 +34,14 @@ export const scalarDescriptor = Scalar({
const driver = new Hono().use(honoLogger()).route("/", driverController);
const app = new Hono()
.use(honoLogger())
.get("*", serveStatic({ root: "./assets/frontend" }))
.get("healthcheck", (c) => c.json({ status: "ok" }))
.basePath("/api/v1")
.route("/volumes", volumeController);
.route("/api/v1/auth", authController.basePath("/api/v1"))
.route("/api/v1/volumes", volumeController.use(requireAuth))
.get("/assets/*", serveStatic({ root: "./assets/frontend" }))
.get("*", serveStatic({ path: "./assets/frontend/index.html" }));
app.get("/openapi.json", generalDescriptor(app));
app.get("/docs", scalarDescriptor);
app.get("/api/v1/openapi.json", generalDescriptor(app));
app.get("/api/v1/docs", scalarDescriptor);
app.onError((err, c) => {
logger.error(`${c.req.url}: ${err.message}`);

View File

@@ -0,0 +1,91 @@
import { validator } from "hono-openapi";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import {
getMeDto,
getStatusDto,
loginBodySchema,
loginDto,
logoutDto,
registerBodySchema,
registerDto,
} from "./auth.dto";
import { authService } from "./auth.service";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
};
export const authController = new Hono()
.post("/register", registerDto, validator("json", registerBodySchema), async (c) => {
const body = c.req.valid("json");
try {
const { user, sessionId } = await authService.register(body.username, body.password);
setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
return c.json({ message: "User registered successfully", user: { id: user.id, username: user.username } }, 201);
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Registration failed" }, 400);
}
})
.post("/login", loginDto, validator("json", loginBodySchema), async (c) => {
const body = c.req.valid("json");
try {
const { sessionId, user, expiresAt } = await authService.login(body.username, body.password);
setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS,
expires: expiresAt,
});
return c.json({
message: "Login successful",
user: { id: user.id, username: user.username },
});
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Login failed" }, 401);
}
})
.post("/logout", logoutDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
await authService.logout(sessionId);
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
}
return c.json({ message: "Logout successful" });
})
.get("/me", getMeDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json({ message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json({ message: "Not authenticated" }, 401);
}
return c.json({
user: session.user,
});
})
.get("/status", getStatusDto, async (c) => {
const hasUsers = await authService.hasUsers();
return c.json({ hasUsers });
});

View File

@@ -0,0 +1,117 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
// Validation schemas
export const loginBodySchema = type({
username: "string>0",
password: "string>7",
});
export const registerBodySchema = type({
username: "string>2",
password: "string>7",
});
const loginResponseSchema = type({
message: "string",
user: type({
id: "string",
username: "string",
}),
});
export const loginDto = describeRoute({
description: "Login with username and password",
operationId: "login",
tags: ["Auth"],
responses: {
200: {
description: "Login successful",
content: {
"application/json": {
schema: resolver(loginResponseSchema),
},
},
},
401: {
description: "Invalid credentials",
},
},
});
export const registerDto = describeRoute({
description: "Register a new user",
operationId: "register",
tags: ["Auth"],
responses: {
201: {
description: "User created successfully",
content: {
"application/json": {
schema: resolver(loginResponseSchema),
},
},
},
400: {
description: "Invalid request or username already exists",
},
},
});
export const logoutDto = describeRoute({
description: "Logout current user",
operationId: "logout",
tags: ["Auth"],
responses: {
200: {
description: "Logout successful",
content: {
"application/json": {
schema: resolver(type({ message: "string" })),
},
},
},
},
});
export const getMeDto = describeRoute({
description: "Get current authenticated user",
operationId: "getMe",
tags: ["Auth"],
responses: {
200: {
description: "Current user information",
content: {
"application/json": {
schema: resolver(loginResponseSchema),
},
},
},
401: {
description: "Not authenticated",
},
},
});
const statusResponseSchema = type({
hasUsers: "boolean",
});
export const getStatusDto = describeRoute({
description: "Get authentication system status",
operationId: "getStatus",
tags: ["Auth"],
responses: {
200: {
description: "Authentication system status",
content: {
"application/json": {
schema: resolver(statusResponseSchema),
},
},
},
},
});
export type LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer;

View File

@@ -0,0 +1,63 @@
import { deleteCookie, getCookie } from "hono/cookie";
import { createMiddleware } from "hono/factory";
import { authService } from "./auth.service";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
};
declare module "hono" {
interface ContextVariableMap {
user: {
id: number;
username: string;
};
}
}
/**
* Middleware to require authentication
* Verifies the session cookie and attaches user to context
*/
export const requireAuth = createMiddleware(async (c, next) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json({ message: "Authentication required" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json({ message: "Invalid or expired session" }, 401);
}
c.set("user", session.user);
await next();
});
/**
* Middleware to optionally attach user if authenticated
* Does not block the request if not authenticated
*/
export const optionalAuth = createMiddleware(async (c, next) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
const session = await authService.verifySession(sessionId);
if (session) {
c.set("user", session.user);
} else {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
}
}
await next();
});

View File

@@ -0,0 +1,139 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
export class AuthService {
/**
* Register a new user with username and password
*/
async register(username: string, password: string) {
const [existingUser] = await db.select().from(usersTable);
if (existingUser) {
throw new Error("Admin user already exists");
}
const passwordHash = await Bun.password.hash(password, {
algorithm: "argon2id",
memoryCost: 19456,
timeCost: 2,
});
const [user] = await db.insert(usersTable).values({ username, passwordHash }).returning();
if (!user) {
throw new Error("User registration failed");
}
logger.info(`User registered: ${username}`);
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
await db.insert(sessionsTable).values({
id: sessionId,
userId: user.id,
expiresAt,
});
return { user: { id: user.id, username: user.username, createdAt: user.createdAt }, sessionId };
}
/**
* Login user with username and password
*/
async login(username: string, password: string) {
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
if (!user) {
throw new Error("Invalid credentials");
}
const isValid = await Bun.password.verify(password, user.passwordHash);
if (!isValid) {
throw new Error("Invalid credentials");
}
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
await db.insert(sessionsTable).values({
id: sessionId,
userId: user.id,
expiresAt,
});
logger.info(`User logged in: ${username}`);
return {
sessionId,
user: { id: user.id, username: user.username },
expiresAt,
};
}
/**
* Logout user by deleting their session
*/
async logout(sessionId: string) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
logger.info(`User logged out: session ${sessionId}`);
}
/**
* Verify a session and return the associated user
*/
async verifySession(sessionId: string) {
const [session] = await db
.select({
session: sessionsTable,
user: usersTable,
})
.from(sessionsTable)
.innerJoin(usersTable, eq(sessionsTable.userId, usersTable.id))
.where(eq(sessionsTable.id, sessionId));
if (!session) {
return null;
}
if (session.session.expiresAt < new Date()) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
return null;
}
return {
user: {
id: session.user.id,
username: session.user.username,
},
session: {
id: session.session.id,
expiresAt: session.session.expiresAt,
},
};
}
/**
* Clean up expired sessions
*/
async cleanupExpiredSessions() {
const result = await db.delete(sessionsTable).where(eq(sessionsTable.expiresAt, new Date())).returning();
if (result.length > 0) {
logger.info(`Cleaned up ${result.length} expired sessions`);
}
}
/**
* Check if any users exist in the system
*/
async hasUsers(): Promise<boolean> {
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
return !!user;
}
}
export const authService = new AuthService();

585
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "ironmount",
"private": true,
"packageManager": "bun@1.2.15",
"packageManager": "bun@1.2.23",
"scripts": {
"dev": "turbo dev",
"tsc": "turbo run tsc",
@@ -16,6 +16,9 @@
],
"devDependencies": {
"@hey-api/openapi-ts": "^0.80.17",
"turbo": "^2.5.6"
}
"turbo": "^2.5.8"
},
"trustedDependencies": [
"@tailwindcss/oxide"
]
}