Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.PHONY: help build up down restart logs logs-follow status clean rebuild validate env-check
.PHONY: backend-run backend-build backend-test backend-test-coverage migrate-create
.PHONY: lint lint-backend lint-ui format format-backend format-ui check setup-tools
.PHONY: lint lint-backend lint-ui format format-backend check setup-tools

# Default target
help:
Expand Down Expand Up @@ -28,11 +28,10 @@ help:
@echo "Linting & Formatting:"
@echo " make lint - Run linters on both backend and UI"
@echo " make lint-backend - Run golangci-lint on backend"
@echo " make lint-ui - Run Biome linter on UI"
@echo " make lint-ui - Lint and format UI with Biome"
@echo " make format - Format both backend and UI code"
@echo " make format-backend - Format Go code with gofmt/goimports"
@echo " make format-ui - Format UI code with Biome"
@echo " make check - Run all checks (lint + format check)"
@echo " make check - Run all checks (lint + tests)"
@echo ""
@echo "Database Migrations:"
@echo " make migrate-create NAME=name - Create new migration files"
Expand Down Expand Up @@ -183,14 +182,14 @@ lint-backend:
@golangci-lint run ./backend/...
@echo "✓ Backend lint complete"

# Lint UI with Biome
# Lint and format UI with Biome
lint-ui:
@echo "Linting UI..."
@cd ui && pnpm lint
@echo "✓ UI lint complete"
@echo "Linting and formatting UI..."
@cd ui && pnpm check:fix
@echo "✓ UI check complete"

# Format all code
format: format-backend format-ui
format: format-backend lint-ui

# Format backend with gofmt and goimports
format-backend:
Expand All @@ -199,12 +198,6 @@ format-backend:
@goimports -w -local github.com/aloks98/waygates backend/
@echo "✓ Backend formatted"

# Format UI with Biome
format-ui:
@echo "Formatting UI..."
@cd ui && pnpm format
@echo "✓ UI formatted"

# Run all checks (lint + tests)
check: lint backend-test
@echo "✓ All checks passed"
Expand Down
3 changes: 0 additions & 3 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@
"dev": "rsbuild dev",
"build": "rsbuild build",
"preview": "rsbuild preview",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
"format": "biome format --write src",
"check": "biome check src",
"check:fix": "biome check --write --unsafe src"
},
Expand Down
76 changes: 43 additions & 33 deletions ui/src/components/proxy/forms/reverse-proxy-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from '@e412/titanium';
import { useForm } from '@tanstack/react-form';
import { Plus, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { z } from 'zod';
import type { CreateReverseProxyRequest, ProxyConfig } from '@/types/proxy';
import { type ACLAssignment, ACLSelector } from './acl-selector';
Expand Down Expand Up @@ -77,6 +77,17 @@ function normalizeUpstreams(
}));
}

// Helper to get a valid lb_strategy value
function getValidLbStrategy(
strategy: string | undefined,
): 'round_robin' | 'least_conn' | 'ip_hash' | 'random' {
const validStrategies = ['round_robin', 'least_conn', 'ip_hash', 'random'] as const;
if (strategy && validStrategies.includes(strategy as (typeof validStrategies)[number])) {
return strategy as (typeof validStrategies)[number];
}
return 'round_robin';
}

export function ReverseProxyForm({
initialData,
initialACLAssignments,
Expand All @@ -89,8 +100,27 @@ export function ReverseProxyForm({
initialACLAssignments ?? [],
);

const form = useForm({
defaultValues: {
// Compute default values based on initialData
// This ensures the form is always initialized with the correct values
const defaultValues = useMemo<ReverseProxyFormValues>(() => {
if (initialData) {
const upstreamData = normalizeUpstreams(initialData);
return {
name: initialData.name || '',
hostname: initialData.hostname || '',
description: initialData.description || '',
upstreams: upstreamData,
ssl_enabled: initialData.ssl_enabled ?? true,
block_exploits: initialData.block_exploits ?? true,
tls_insecure_skip_verify: initialData.tls_insecure_skip_verify ?? false,
lb_strategy: getValidLbStrategy(initialData.load_balancing?.strategy),
health_check_enabled: initialData.load_balancing?.health_checks?.enabled ?? false,
health_check_path: initialData.load_balancing?.health_checks?.path || '/health',
health_check_interval: initialData.load_balancing?.health_checks?.interval || '30s',
health_check_timeout: initialData.load_balancing?.health_checks?.timeout || '5s',
};
}
return {
name: '',
hostname: '',
description: '',
Expand All @@ -103,7 +133,11 @@ export function ReverseProxyForm({
health_check_path: '/health',
health_check_interval: '30s',
health_check_timeout: '5s',
} as ReverseProxyFormValues,
};
}, [initialData]);

const form = useForm({
defaultValues,
validators: {
onSubmit: reverseProxySchema,
},
Expand Down Expand Up @@ -139,38 +173,14 @@ export function ReverseProxyForm({
},
});

// Reset form when initialData changes (for edit mode)
// Use defaultValues from useMemo to avoid duplication
useEffect(() => {
if (initialData) {
const upstreamData = normalizeUpstreams(initialData);

setUpstreams(upstreamData);

form.setFieldValue('name', initialData.name || '');
form.setFieldValue('hostname', initialData.hostname || '');
form.setFieldValue('description', initialData.description || '');
form.setFieldValue('upstreams', upstreamData);
form.setFieldValue('ssl_enabled', initialData.ssl_enabled ?? true);
form.setFieldValue('block_exploits', initialData.block_exploits ?? true);
form.setFieldValue('tls_insecure_skip_verify', initialData.tls_insecure_skip_verify ?? false);
form.setFieldValue('lb_strategy', initialData.load_balancing?.strategy || 'round_robin');
form.setFieldValue(
'health_check_enabled',
initialData.load_balancing?.health_checks?.enabled ?? false,
);
form.setFieldValue(
'health_check_path',
initialData.load_balancing?.health_checks?.path || '/health',
);
form.setFieldValue(
'health_check_interval',
initialData.load_balancing?.health_checks?.interval || '30s',
);
form.setFieldValue(
'health_check_timeout',
initialData.load_balancing?.health_checks?.timeout || '5s',
);
setUpstreams(normalizeUpstreams(initialData));
form.reset(defaultValues);
}
}, [initialData, form.setFieldValue]);
}, [initialData, form, defaultValues]);

// Update ACL assignments when initialACLAssignments changes (async load)
useEffect(() => {
Expand Down
30 changes: 19 additions & 11 deletions ui/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ky, { type KyInstance, type Options } from 'ky';
import ky, { type KyInstance } from 'ky';
import { useAuthStore } from '../stores/auth';
import type { ApiResponse, TokenPair } from '../types/api';

Expand Down Expand Up @@ -61,20 +61,28 @@ export const api: KyInstance = ky.create({
},
],
afterResponse: [
async (request, options, response) => {
if (response.status === 401 && !request.url.includes('/auth/refresh')) {
async (request, _options, response) => {
// Skip refresh logic for auth endpoints to prevent loops
if (response.status === 401 && !request.url.includes('/auth/')) {
const tokens = await handleTokenRefresh();

if (tokens) {
const retryOptions: Options = {
...options,
headers: {
...Object.fromEntries(request.headers.entries()),
Authorization: `Bearer ${tokens.access_token}`,
},
};
return ky(request.url, retryOptions);
// Use native fetch for retry to avoid ky's prefixUrl handling
// request.url is already the full absolute URL
const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${tokens.access_token}`);

return fetch(request.url, {
method: request.method,
headers,
body: request.body,
credentials: request.credentials,
});
}

// Refresh failed - ensure user is logged out
const { logout } = useAuthStore.getState();
logout();
}
return response;
},
Expand Down
34 changes: 21 additions & 13 deletions ui/src/routes/_dashboard/route.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { Outlet } from '@tanstack/react-router';
import { useEffect } from 'react';
import { AppSidebar } from '@/components/layout';
Expand All @@ -9,21 +10,28 @@ import type { User } from '@/types/auth';
export function DashboardLayout() {
const { setUser, user } = useAuthStore();

const { data, isLoading } = useQuery({
queryKey: ['auth', 'me'],
queryFn: () => api.get('auth/me').json<ApiResponse<User>>(),
enabled: !user, // Only fetch if user is not already in store
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
});

// Sync fetched user to auth store
useEffect(() => {
if (!user) {
api
.get('auth/me')
.json<ApiResponse<User>>()
.then((response) => {
if (response.success && response.data) {
setUser(response.data);
}
})
.catch(() => {
// Handle error silently, auth middleware will handle redirect
});
if (data?.success && data.data && !user) {
setUser(data.data);
}
}, [user, setUser]);
}, [data, setUser, user]);

// Show loading state while fetching user data
if (!user && isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}

return (
<div className="flex h-screen">
Expand Down