Skip to content
Open
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
1 change: 0 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
engine-strict=true
resolution-mode=highest
7 changes: 6 additions & 1 deletion Caddyfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
:{$PORT:80}
root * /app
encode gzip zstd
try_files {path}.html {path}

# SPA fallback for /admin path
handle /admin* {
try_files {path} /admin/index.html
}

file_server
1,328 changes: 1,090 additions & 238 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"ipaddr.js": "^2.2.0",
"js-xxhash": "^4.0.0",
"json5": "^2.2.3",
"svelte-i18n": "^4.0.1",
"svelte-jsoneditor": "^2.4.0",
"unplugin-icons": "^22.1.0"
}
Expand Down
39 changes: 36 additions & 3 deletions src/app.html
Original file line number Diff line number Diff line change
@@ -1,14 +1,47 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' *;">

%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" data-theme="skeleton">
<script>
document.body.dataset['theme'] = localStorage.getItem('theme') ?? 'skeleton';
// Theme (Colors)
try {
const theme = localStorage.getItem('theme');
if (theme) {
document.body.dataset['theme'] = JSON.parse(theme);
} else {
document.body.dataset['theme'] = 'skeleton';
}
} catch (e) {
document.body.dataset['theme'] = 'skeleton';
}

// Mode (Dark/Light) - Restore Skeleton LightSwitch state
try {
const modeUserPref = localStorage.getItem('modeUserPref');

// Skeleton uses 'false' string for light, 'true' for dark, or null for OS preference
if (modeUserPref === 'true') {
document.documentElement.classList.add('dark');
} else if (modeUserPref === 'false') {
document.documentElement.classList.remove('dark');
} else {
// Fallback to OS preference if no user preference is set
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
} catch (e) {
console.error(e);
}
</script>
<div style="display: contents" class="h-full overflow-hidden">
%sveltekit.body%
Expand Down
2 changes: 1 addition & 1 deletion src/app.postcss
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ body {
*/
html,
body {
@apply overflow-y-scroll;
@apply h-full overflow-hidden;
}

/* modern theme */
Expand Down
22 changes: 12 additions & 10 deletions src/lib/Navigation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
import RawMdiRouter from '~icons/mdi/router';
import RawMdiSecurity from '~icons/mdi/security';
import RawMdiSettings from '~icons/mdi/settings';
import RawMdiChartLine from '~icons/mdi/chart-line';

// import { ApiKeyInfoStore, ApiKeyStore, hasValidApi } from './Stores';
import { onMount, type Component } from 'svelte';
import { page } from '$app/state';
import { App } from '$lib/States.svelte';
import { _ } from 'svelte-i18n';

type NavigationProps = {
labels?: boolean
Expand All @@ -26,7 +28,7 @@
const DrawerStore = getDrawerStore();

function classesActive(href: string): string {
return href === page.route.id ? 'bg-primary-300 dark:bg-primary-700' : '';
return href === page.route.id ? 'variant-filled-primary' : '';
}

let newPath = $state('');
Expand All @@ -37,18 +39,18 @@

type Page = {
path: string;
name: string;
nameKey: string;
logo: Component;
};

const allPages: Page[] = [
{ path: '/', name: 'Home', logo: RawMdiHome },
{ path: '/users', name: 'Users', logo: RawMdiPerson },
{ path: '/nodes', name: 'Nodes', logo: RawMdiDevices },
{ path: '/deploy', name: 'Deploy', logo: RawMdiHomeGroupPlus },
{ path: '/routes', name: 'Routes', logo: RawMdiRouter },
{ path: '/acls', name: 'ACLs', logo: RawMdiSecurity },
{ path: '/settings', name: 'Settings', logo: RawMdiSettings },
{ path: '/', nameKey: 'navigation.home', logo: RawMdiHome },
{ path: '/users', nameKey: 'navigation.users', logo: RawMdiPerson },
{ path: '/nodes', nameKey: 'navigation.nodes', logo: RawMdiDevices },
{ path: '/deploy', nameKey: 'navigation.deploy', logo: RawMdiHomeGroupPlus },
{ path: '/routes', nameKey: 'navigation.routes', logo: RawMdiRouter },
{ path: '/acls', nameKey: 'navigation.acls', logo: RawMdiSecurity },
{ path: '/settings', nameKey: 'navigation.settings', logo: RawMdiSettings },
].filter((p) => p != undefined);

const pages = $derived.by(() => App.hasValidApi ? allPages : allPages.slice(-1));
Expand All @@ -70,7 +72,7 @@
<p.logo />
<!--svelte:component this={p.logo} class="mr-4" /-->
{#if labels}
<span class="text-sm ml-2">{p.name}</span>
<span class="text-sm ml-2">{$_(p.nameKey)}</span>
{/if}
</span>
</a>
Expand Down
90 changes: 72 additions & 18 deletions src/lib/States.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { ToastStore } from '@skeletonlabs/skeleton';
import { apiGet } from './common/api';
import { arraysEqual, clone, toastError, toastWarning } from './common/funcs';
import { debug } from './common/debug';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';

export type LayoutStyle = 'tile' | 'list';

Expand Down Expand Up @@ -40,12 +42,12 @@ export class State<T> {
}


// state that is wrapped in LocalStorage
// state that is wrapped in LocalStorage or SessionStorage
export class StateLocal<T> {
#key: string;
#value = $state<T>() as T;
#effect?: (value?: T) => void;
// #saver = $derived(this.save(this.#value))
#session: boolean;

get key() {
return this.#key;
Expand All @@ -57,31 +59,34 @@ export class StateLocal<T> {

set value(value: T) {
this.#value = value;
// this.save(this.#value);
if(this.#effect !== undefined) {
this.#effect(value);
}
}

get storage(): Storage | null {
if (!browser) return null;
return this.#session ? sessionStorage : localStorage;
}

save(value: T) {
debug(`Saving '${this.#key}' in localStorage...`);
localStorage.setItem(this.#key, this.serialize(value));
debug(`Saving '${this.#key}' in ${this.#session ? 'sessionStorage' : 'localStorage'}...`);
this.storage?.setItem(this.#key, this.serialize(value));
}


constructor(key: string, valueDefault: T, effect?: (value?: T) => void) {
constructor(key: string, valueDefault: T, effect?: (value?: T) => void, session: boolean = false) {
this.#key = key;
this.#effect = effect;
this.#value = valueDefault;
this.#session = session;

if(browser){
const storedValue = localStorage.getItem(this.#key);
const storedValue = this.storage?.getItem(this.#key);
if (storedValue) {
this.#value = this.deserialize(storedValue);
} else {
this.#value = valueDefault;
}

// how do I clean this up?
$effect.root(()=>{
$effect(()=>{
this.save(this.#value);
Expand Down Expand Up @@ -116,19 +121,39 @@ export class HeadscaleAdmin {
}
})

// language settings
language = new StateLocal<string>('locale', 'en')

// api info
apiValid = $state<boolean>(false);
apiUrl = new StateLocal<string>('apiUrl', '');
apiKey = new StateLocal<string>('apiKey', '');

#apiKey = new StateLocal<string>('apiKey', '');
#apiKeySession = new StateLocal<string>('apiKey', '', undefined, true);

get apiKey(): StateLocal<string> {
const key = this.apiRememberMe.value ? this.#apiKey : this.#apiKeySession;
// If we switched from remember to not remember, or vice versa, ensure the key is transferred
if (this.apiRememberMe.value && !this.#apiKey.value && this.#apiKeySession.value) {
this.#apiKey.value = this.#apiKeySession.value;
this.#apiKeySession.value = '';
} else if (!this.apiRememberMe.value && !this.#apiKeySession.value && this.#apiKey.value) {
this.#apiKeySession.value = this.#apiKey.value;
this.#apiKey.value = '';
}
return key;
}

apiRememberMe = new StateLocal<boolean>('apiRememberMe', true);
apiTtl = new StateLocal<number>('apiTTL', 10000);
apiKeyInfo = new StateLocal<ApiKeyInfo>('apiKeyInfo', {
authorized: null,
expires: '',
informedUnauthorized: false,
informedExpiringSoon: false,
})
hasApiKey = $derived<boolean>(isInitialized() && !!this.apiKey.value)
hasApiUrl = $derived<boolean>(isInitialized() && !!this.apiUrl.value)
hasApiKey = $derived(isInitialized() && !!this.apiKey.value)
hasApiUrl = $derived(isInitialized() && !!this.apiUrl.value)
hasApi = $derived(this.hasApiKey && this.hasApiUrl)
hasValidApi = $derived(this.hasApi && this.apiKeyInfo.value.authorized === true)

Expand Down Expand Up @@ -212,6 +237,28 @@ export class HeadscaleAdmin {
if (preAuthKeys === undefined) {
preAuthKeys = await getPreAuthKeys()
}

// Keep full keys (no asterisks) for a given ID if the new one is masked
// This preserves the initially shown full key when API refreshes show masked version
preAuthKeys = preAuthKeys.map((newKey) => {
const existingKey = this.preAuthKeys.value.find(k => k.id === newKey.id);

// Safety checks for key field
if (existingKey &&
newKey.key && existingKey.key &&
typeof newKey.key === 'string' &&
typeof existingKey.key === 'string') {

// If new key is masked (has asterisks) and existing is not, keep the full key
if (newKey.key.includes('*') && !existingKey.key.includes('*')) {
debug('Preserving full key for ID', newKey.id, '- old:', existingKey.key.substring(0, 20), 'new:', newKey.key.substring(0, 20));
return existingKey;
}
}

return newKey;
});

if(!arraysEqual(this.preAuthKeys.value, preAuthKeys)){
this.preAuthKeys.value = [...preAuthKeys]
return true
Expand All @@ -221,10 +268,17 @@ export class HeadscaleAdmin {

async populateApiKeyInfo(): Promise<boolean> {
const { apiKeys } = await apiGet<ApiApiKeys>(`/api/v1/apikey`);
const myKey = apiKeys.filter((key) => this.apiKey.value.startsWith(key.prefix))[0];
const myKey = apiKeys.find((key) => {
const cleanPrefix = key.prefix.replace(/\*+$/, '');
return this.apiKey.value.startsWith(cleanPrefix);
});

const apiKeyInfo = this.apiKeyInfo.value
apiKeyInfo.expires = myKey.expiration;
apiKeyInfo.authorized = true;
if (myKey) {
apiKeyInfo.expires = myKey.expiration;
}

this.apiKeyInfo.value = {...apiKeyInfo};
return true;
}
Expand Down Expand Up @@ -267,7 +321,7 @@ export class HeadscaleAdmin {
}
}

export const App = $state<HeadscaleAdmin>(new HeadscaleAdmin())
export const App = new HeadscaleAdmin()


function isInitialized(): boolean {
Expand All @@ -293,7 +347,7 @@ export function informUserUnauthorized(toastStore: ToastStore) {
}
App.apiKeyInfo.value.informedUnauthorized = true;
App.apiKeyInfo.value.authorized = false;
toastError('API Key is Unauthorized or Invalid', toastStore);
toastError(get(_)( 'settings.unauthorizedMessage' ), toastStore);
});
}

Expand All @@ -304,6 +358,6 @@ export function informUserExpiringSoon(toastStore: ToastStore) {
}
App.apiKeyInfo.value.informedUnauthorized = true;
App.apiKeyInfo.value.authorized = false;
toastWarning('API Key Expires Soon', toastStore);
toastWarning(get(_)( 'settings.expiringSoonMessage' ), toastStore);
});
}
2 changes: 1 addition & 1 deletion src/lib/cards/CardListContainer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
let { children } = $props()
</script>

<div class="grid grid-cols-1 bg-white dark:bg-slate-800 rounded-md p-5">
<div class="grid grid-cols-1 bg-surface-50-900-token border border-surface-500/30 rounded-md p-5">
{@render children()}
</div>
2 changes: 1 addition & 1 deletion src/lib/cards/CardListItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<AccordionItem
{open}
{id}
class="backdrop-blur-xl backdrop-brightness-100 bg-white/25 dark:bg-white/5 rounded-md"
class="backdrop-blur-xl backdrop-brightness-100 bg-surface-50-900-token border border-surface-500/30 rounded-md"
padding="py-4 px-4"
regionControl="!rounded-none"
>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/cards/CardListPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
let { children }: CardListPageProps = $props()
</script>

<div class="flex items-center mr-2 ml-0 pt-2">
<div class="flex items-center ml-0 pt-2">
<Accordion class="w-full md:w-8/12 lg:w-9/12 xl:w-8/12 2xl:w-6/12">
{@render children()}
</Accordion>
Expand Down
26 changes: 19 additions & 7 deletions src/lib/cards/CardTileContainer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,36 @@

type CardTileContainerProps = {
classes?: string,
onclick?: MouseEventHandler<HTMLButtonElement>,
onclick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>,
href?: string,
children: Snippet,
}
let {
classes = '',
onclick = undefined,
href = undefined,
children,
}: CardTileContainerProps = $props()

const classesFinal = $derived(
'col-span-12 xs:col-span-12 sm:col-span-6 md:col-span-6 lg:col-span-6 xl:col-span-4 2xl:col-span-3 card ' +
(onclick === undefined ? '' : 'card-hover ') +
'col-span-12 xs:col-span-12 sm:col-span-6 md:col-span-6 lg:col-span-6 xl:col-span-4 2xl:col-span-3 card transition-all duration-200 ' +
(onclick === undefined && href === undefined ? '' : 'card-hover cursor-pointer ') +
classes
);
</script>

<div class="{classesFinal} mr-4 my-2">
<button class="gap-20 w-full px-4 py-2" onclick={onclick}>
{@render children()}
</button>
<div class="{classesFinal} mr-4 my-2 overflow-hidden">
{#if href}
<a {href} class="block w-full h-full px-4 py-3" onclick={onclick} data-sveltekit-preload-data="hover">
{@render children()}
</a>
{:else if onclick}
<button class="block w-full h-full px-4 py-3 text-left" onclick={onclick}>
{@render children()}
</button>
{:else}
<div class="px-4 py-3">
{@render children()}
</div>
{/if}
</div>
Loading