web-admin: Add domains pages

This commit is contained in:
nemunaire 2026-01-21 21:09:29 +08:00
commit abe58144b2
9 changed files with 862 additions and 5 deletions

View file

@ -26,14 +26,16 @@
Container,
} from "@sveltestrap/sveltestrap";
import { getUsers } from '$lib/api-admin';
import { getDomains, getUsers } from '$lib/api-admin';
import DatabaseBackupCard from "./DatabaseBackupCard.svelte";
let totalUsers: number | undefined = $state();
getUsers().then((res) => { totalUsers = res.data?.length || 0; });
let totalDomains: number | undefined = $state();
getDomains().then((res) => { totalDomains = res.data?.length || 0; });
let stats = {
totalDomains: 0,
activeProviders: 0
};
</script>
@ -72,7 +74,7 @@
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Total Domains</h6>
<h2 class="mb-0">{stats.totalDomains}</h2>
<h2 class="mb-0">{totalDomains}</h2>
</div>
<div class="text-primary">
<i class="bi bi-globe" style="font-size: 2rem;"></i>

View file

@ -0,0 +1,141 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Button,
Card,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
} from "@sveltestrap/sveltestrap";
import { getDomains, deleteDomainsByDomain } from '$lib/api-admin';
import { toasts } from '$lib/stores/toasts';
let domainsQ = $state(getDomains());
let searchQuery = $state('');
async function handleDeleteDomain(domainId: string, domainName: string) {
if (confirm(`Are you sure you want to delete domain "${domainName}"?`)) {
try {
await deleteDomainsByDomain({ path: { domain: domainId } });
// Refresh the domains list
domainsQ = getDomains();
toasts.addToast({
message: `Domain "${domainName}" has been deleted successfully`,
type: 'success',
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: 'Failed to delete domain: ' + error,
timeout: 10000,
});
}
}
}
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col md={8}>
<h1 class="display-5">
<Icon name="globe"></Icon>
Domain Management
</h1>
<p class="d-flex gap-3 align-items-center text-muted">
<span class="lead">
Manage all domains
</span>
{#await domainsQ then domainsR}
<span>Total: {domainsR.data?.length ?? 0} domains</span>
{/await}
</p>
</Col>
</Row>
<Row class="mb-4">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input
type="text"
placeholder="Search domains..."
bind:value={searchQuery}
/>
</InputGroup>
</Col>
</Row>
{#await domainsQ}
Please wait...
{:then domainsR}
{@const domains = domainsR.data ?? []}
<div class="table-responsive">
<Table hover bordered>
<thead>
<tr>
<th>ID</th>
<th>Domain Name</th>
<th>Group</th>
<th>Owner ID</th>
<th>Provider ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each domains.filter(d =>
(d.id && d.id.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) ||
(d.domain && d.domain.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) ||
(d.group && d.group.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1)
) as domain}
<tr>
<td>{domain.id}</td>
<td>{domain.domain}</td>
<td>{domain.group || '-'}</td>
<td>{domain.id_owner || '-'}</td>
<td>{domain.id_provider || '-'}</td>
<td class="d-flex flex-nowrap gap-1">
<Button color="primary" outline size="sm" href="/users/{domain.id_owner}/domains/{domain.id}">
<Icon name="pencil"></Icon>
</Button>
<Button color="primary" outline size="sm" onclick={() => handleDeleteDomain(domain.id || '', domain.domain || '')}>
<Icon name="trash"></Icon>
</Button>
</td>
</tr>
{/each}
</tbody>
</Table>
</div>
{/await}
</Container>

View file

@ -0,0 +1,43 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { redirect } from '@sveltejs/kit';
import { getDomains } from '$lib/api-admin';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
const domainId = params.domain;
// Fetch all domains to find the owner
const domainsResponse = await getDomains();
if (domainsResponse.data) {
const domain = domainsResponse.data.find(d => d.id === domainId);
if (domain && domain.id_owner) {
// Redirect to the user-specific domain route
throw redirect(302, `/users/${domain.id_owner}/domains/${domainId}`);
}
}
// If domain not found or no owner, throw 404
throw redirect(302, '/domains');
};

View file

@ -25,6 +25,7 @@
import { page } from '$app/stores';
import {
Alert,
Badge,
Button,
Col,
Container,
@ -33,12 +34,13 @@
Spinner,
} from "@sveltestrap/sveltestrap";
import { getUsersByUid, type HappydnsDomain } from '$lib/api-admin';
import { client } from '$lib/api-admin/client.gen';
import { getUsersByUid, getUsersByUidDomains } from '$lib/api-admin';
import UserInfoCard from './UserInfoCard.svelte';
import UserDomainsCard from './domains/UserDomainsCard.svelte';
const uid = $page.params.uid!;
let userQ = $state(getUsersByUid({ path: { uid } }));
let domainsQ = $state(getUsersByUidDomains({ path: { uid } }));
</script>
<Container class="flex-fill my-5">
@ -63,6 +65,10 @@
<Col md={8} lg={6}>
<UserInfoCard {user} {uid} />
</Col>
<Col md={8} lg={6}>
<UserDomainsCard {domainsQ} userId={user.id!} />
</Col>
</Row>
{:else}
<Alert color="warning">

View file

@ -0,0 +1,55 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { page } from '$app/stores';
import {
Button,
Col,
Container,
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { getUsersByUidDomains } from '$lib/api-admin';
import UserDomainsCard from './UserDomainsCard.svelte';
let userId = $derived($page.params.uid!);
let domainsQ = $derived(getUsersByUidDomains({ path: { uid: userId }}));
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<div class="d-flex align-items-center gap-1">
<Button color="link" href="/users/{userId}" class="text-black">
<Icon name="chevron-left"></Icon>
</Button>
<h1 class="display-5 mb-0">
User Domains
</h1>
</div>
</Col>
</Row>
<UserDomainsCard {domainsQ} {userId} />
</Container>

View file

@ -0,0 +1,99 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Alert,
Badge,
Card,
CardBody,
CardHeader,
Icon,
ListGroup,
ListGroupItem,
Spinner,
} from "@sveltestrap/sveltestrap";
import type { HappydnsDomain, HappydnsErrorResponse } from '$lib/api-admin';
interface UserDomainsCardProps {
domainsQ: Promise<(
| { data: HappydnsDomain[]; error: undefined }
| { data: undefined; error: HappydnsErrorResponse }
) & { request: Request; response: Response }>;
userId: string;
}
let { domainsQ, userId }: UserDomainsCardProps = $props();
</script>
{#await domainsQ}
<Card>
<CardBody>
<div class="text-center">
<Spinner color="primary" size="sm" />
<span class="ms-2">Loading domains...</span>
</div>
</CardBody>
</Card>
{:then domainsR}
{@const userDomains = domainsR.data || []}
<Card>
<CardHeader>
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<Icon name="globe"></Icon>
User Domains
</h5>
<Badge color="secondary">{userDomains.length} domains</Badge>
</div>
</CardHeader>
{#if userDomains.length === 0}
<CardBody>
<p class="text-muted mb-0">This user has no domains.</p>
</CardBody>
{:else}
<ListGroup flush>
{#each userDomains as domain}
<ListGroupItem href="/users/{userId}/domains/{domain.id}" action>
<strong>{domain.domain}</strong>
{#if domain.group}
<Badge color="info" class="ms-2">{domain.group}</Badge>
{/if}
<div class="small text-muted">
<code>{domain.id}</code>
</div>
</ListGroupItem>
{/each}
</ListGroup>
{/if}
</Card>
{:catch}
<Card>
<CardBody>
<Alert color="warning" class="mb-0">
Unable to load domains.
</Alert>
</CardBody>
</Card>
{/await}

View file

@ -0,0 +1,109 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { page } from '$app/stores';
import {
Alert,
Button,
Col,
Container,
Icon,
Row,
Spinner,
} from "@sveltestrap/sveltestrap";
import { getUsersByUidDomainsByDomain } from '$lib/api-admin';
import DomainInformationCard from './DomainInformationCard.svelte';
import ZoneHistoryCard from './zones/ZoneHistoryCard.svelte';
const uid = $derived($page.params.uid!);
const domainId = $derived($page.params.domain!);
let domainQ = $derived(getUsersByUidDomainsByDomain({ path: { uid, domain: domainId } }));
let zoneHistory = $state<string[]>([]);
// Load domain data when promise resolves
$effect(() => {
domainQ.then(response => {
if (response?.data && response.data.length > 0) {
zoneHistory = response.data[0].zone_history || [];
}
});
});
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<h1 class="display-5">
<Icon name="pencil"></Icon>
Edit Domain
</h1>
</Col>
</Row>
{#await domainQ}
<div class="text-center my-5">
<Spinner color="primary" />
<p class="mt-3">Loading domain...</p>
</div>
{:then domainR}
{#if domainR?.data && domainR.data.length > 0}
{@const domain = domainR.data[0]}
<Row>
<Col md={8} lg={6}>
<DomainInformationCard
domainData={domain}
{uid}
{domainId}
/>
</Col>
<Col md={8} lg={6}>
<ZoneHistoryCard {domainId} {uid} {zoneHistory} />
</Col>
</Row>
{:else}
<Alert color="warning">
<h4 class="alert-heading">No data available</h4>
<p>The domain response did not contain any data.</p>
<hr />
<Button type="button" color="secondary" outline href="/domains">
<Icon name="arrow-left"></Icon>
Back to Domains
</Button>
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<h4 class="alert-heading">Error loading domain</h4>
<p>{error}</p>
<hr />
<Button type="button" color="secondary" outline href="/domains">
<Icon name="arrow-left"></Icon>
Back to Domains
</Button>
</Alert>
{/await}
</Container>

View file

@ -0,0 +1,220 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import {
Alert,
Button,
Card,
CardBody,
CardHeader,
Form,
FormGroup,
Icon,
Input,
InputGroup,
Label,
Spinner,
} from "@sveltestrap/sveltestrap";
import { putUsersByUidDomainsByDomain } from '$lib/api-admin';
import { toasts } from '$lib/stores/toasts';
interface Props {
domainData: any;
uid: string;
domainId: string;
}
let { domainData, uid, domainId }: Props = $props();
let domainName = $state('');
let group = $state('');
let id_owner = $state('');
let id_provider = $state('');
let loading = $state(false);
let errorMessage = $state('');
// Update local state when domainData changes
$effect(() => {
if (domainData) {
domainName = domainData.domain || '';
group = domainData.group || '';
id_owner = domainData.id_owner || '';
id_provider = domainData.id_provider || '';
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
loading = true;
errorMessage = '';
try {
await putUsersByUidDomainsByDomain({
path: { uid, domain: domainId },
body: {
domain: domainName,
group: group || undefined,
id_owner: id_owner || undefined,
id_provider: id_provider || undefined,
}
});
toasts.addToast({
message: `Domain "${domainName}" has been updated successfully`,
type: 'success',
timeout: 5000,
});
goto('/domains');
} catch (error) {
errorMessage = 'Failed to update domain: ' + error;
toasts.addErrorToast({
message: errorMessage,
timeout: 10000,
});
} finally {
loading = false;
}
}
function handleCancel() {
goto('/domains');
}
</script>
<Card class="mb-4">
<CardHeader>
<h5 class="mb-0">Domain Information</h5>
</CardHeader>
<CardBody>
{#if errorMessage}
<Alert color="danger" dismissible fade>
{errorMessage}
</Alert>
{/if}
<Form on:submit={handleSubmit}>
<FormGroup>
<Label for="domainId">Domain ID</Label>
<Input
type="text"
id="domainId"
value={domainData?.id || ''}
disabled
readonly
/>
</FormGroup>
<FormGroup>
<Label for="domainName">Domain Name (FQDN) *</Label>
<Input
type="text"
id="domainName"
bind:value={domainName}
required
placeholder="example.com"
/>
<small class="form-text text-muted">
The fully qualified domain name.
</small>
</FormGroup>
<FormGroup>
<Label for="group">Group</Label>
<Input
type="text"
id="group"
bind:value={group}
placeholder="production"
/>
<small class="form-text text-muted">
Optional hint string to group domains together.
</small>
</FormGroup>
<FormGroup>
<Label for="id_owner">Owner ID</Label>
<InputGroup>
<Input
type="text"
id="id_owner"
bind:value={id_owner}
placeholder="owner-id"
/>
<Button
color="secondary"
outline
href="/users/{id_owner}"
disabled={!id_owner}
>
<Icon name="arrow-right"></Icon>
</Button>
</InputGroup>
</FormGroup>
<FormGroup>
<Label for="id_provider">Provider ID</Label>
<InputGroup>
<Input
type="text"
id="id_provider"
bind:value={id_provider}
placeholder="provider-id"
/>
<Button
color="secondary"
outline
href="/providers/{id_provider}"
disabled={!id_provider}
>
<Icon name="arrow-right"></Icon>
</Button>
</InputGroup>
</FormGroup>
<div class="d-flex gap-2 mt-4">
<Button color="primary" type="submit" disabled={loading}>
{#if loading}
<Spinner size="sm" class="me-2" />
{:else}
<Icon name="check-circle" class="me-2"></Icon>
{/if}
Save Changes
</Button>
<Button
type="button"
color="secondary"
outline
onclick={handleCancel}
disabled={loading}
>
Cancel
</Button>
</div>
</Form>
</CardBody>
</Card>

View file

@ -0,0 +1,182 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import {
Alert,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Form,
FormGroup,
Icon,
Input,
Label,
Row,
Spinner,
} from "@sveltestrap/sveltestrap";
import { postDomains } from '$lib/api-admin';
import { toasts } from '$lib/stores/toasts';
let domain = $state('');
let group = $state('');
let id_owner = $state('');
let id_provider = $state('');
let loading = $state(false);
let errorMessage = $state('');
async function handleSubmit() {
loading = true;
errorMessage = '';
try {
const response = await postDomains({
body: {
domain: domain,
group: group || undefined,
id_owner: id_owner || undefined,
id_provider: id_provider || undefined,
}
});
toasts.addToast({
message: `Domain "${domain}" has been created successfully`,
type: 'success',
timeout: 5000,
});
goto('/domains');
} catch (error) {
errorMessage = 'Failed to create domain: ' + error;
toasts.addErrorToast({
message: errorMessage,
timeout: 10000,
});
} finally {
loading = false;
}
}
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<h1 class="display-5">
<Icon name="plus-circle"></Icon>
Create New Domain
</h1>
</Col>
</Row>
<Row>
<Col md={8} lg={6}>
<Card>
<CardHeader>
<h5 class="mb-0">Domain Information</h5>
</CardHeader>
<CardBody>
{#if errorMessage}
<Alert color="danger" dismissible fade>
{errorMessage}
</Alert>
{/if}
<Form on:submit={handleSubmit}>
<FormGroup>
<Label for="domain">Domain Name (FQDN) *</Label>
<Input
type="text"
id="domain"
bind:value={domain}
required
placeholder="example.com"
autofocus
/>
<small class="form-text text-muted">
The fully qualified domain name to manage.
</small>
</FormGroup>
<FormGroup>
<Label for="group">Group</Label>
<Input
type="text"
id="group"
bind:value={group}
placeholder="production"
/>
<small class="form-text text-muted">
Optional hint string to group domains together.
</small>
</FormGroup>
<FormGroup>
<Label for="id_owner">Owner ID</Label>
<Input
type="text"
id="id_owner"
bind:value={id_owner}
placeholder="owner-id"
/>
<small class="form-text text-muted">
The identifier of the domain's owner.
</small>
</FormGroup>
<FormGroup>
<Label for="id_provider">Provider ID</Label>
<Input
type="text"
id="id_provider"
bind:value={id_provider}
placeholder="provider-id"
/>
<small class="form-text text-muted">
The identifier of the provider used to access and edit the domain.
</small>
</FormGroup>
<div class="d-flex gap-2 mt-4">
<Button color="primary" type="submit" disabled={loading}>
{#if loading}
<Spinner size="sm" class="me-2" />
{:else}
<Icon name="plus-circle" class="me-2"></Icon>
{/if}
Create Domain
</Button>
<Button type="button" color="secondary" outline href="/domains" disabled={loading}>
Cancel
</Button>
</div>
</Form>
</CardBody>
</Card>
</Col>
</Row>
</Container>