model: add UserQuota struct for admin-controlled per-user limits

Introduce a UserQuota field on the User model to hold admin-controlled
limits and flags that the user cannot modify. Only checker-related
fields are defined for now (max checks per day, retention days,
inactivity pause days, scheduling kill switch); future paid-plan
attributes will be added here later.

The user-facing API only exposes settings updates and account deletion,
so Quota cannot be written through it. Updates go through the existing
admin user PUT endpoint, with a new editor card in the admin UI under
/users/[uid].
This commit is contained in:
nemunaire 2026-04-08 11:46:32 +07:00
commit 5ec22c6678
4 changed files with 256 additions and 1 deletions

View file

@ -42,6 +42,10 @@ type User struct {
// Settings holds the settings for an account.
Settings UserSettings `json:"settings" binding:"required"`
// Quota holds admin-controlled limits for the account. It is never
// writable through the user-facing API; only the admin API can update it.
Quota UserQuota `json:"quota"`
}
func (u *User) GetUserId() Identifier {

51
model/user_quota.go Normal file
View file

@ -0,0 +1,51 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-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/>.
package happydns
import "time"
// UserQuota holds admin-controlled per-user limits and flags. These fields are
// never modifiable by the user; they can only be updated through the admin API.
//
// Only checker-related fields are defined for now. Future paid-plan attributes
// (plan tier, domain caps, payment metadata, ...) will be added here later.
type UserQuota struct {
// MaxChecksPerDay caps the number of checker executions per day for this
// user. 0 means "use the system default".
MaxChecksPerDay int `json:"max_checks_per_day,omitempty"`
// RetentionDays is the maximum age (in days) of checker executions kept in
// storage for this user. 0 means "use the system default".
RetentionDays int `json:"retention_days,omitempty"`
// InactivityPauseDays is the number of days without login after which the
// scheduler stops running checks for this user. 0 means "use the system
// default". A negative value disables the inactivity pause for this user.
InactivityPauseDays int `json:"inactivity_pause_days,omitempty"`
// SchedulingPaused, when true, completely disables the scheduler for this
// user (admin kill switch).
SchedulingPaused bool `json:"scheduling_paused,omitempty"`
// UpdatedAt records the last time these quotas were modified.
UpdatedAt time.Time `json:"updated_at,omitzero" format:"date-time"`
}

View file

@ -27,6 +27,7 @@
import { getUsersByUid, getUsersByUidDomains, getUsersByUidProviders } from "$lib/api-admin";
import UserInfoCard from "./UserInfoCard.svelte";
import UserQuotaCard from "./UserQuotaCard.svelte";
import UserDomainsCard from "./domains/UserDomainsCard.svelte";
import UserProvidersCard from "./providers/UserProvidersCard.svelte";
@ -55,8 +56,9 @@
{@const user = userR.data}
{#if user}
<Row>
<Col md={8} lg={6}>
<Col md={8} lg={6} class="d-flex flex-column gap-4">
<UserInfoCard {user} {uid} />
<UserQuotaCard {user} {uid} />
</Col>
<Col md={8} lg={6} class="d-flex flex-column gap-4">

View file

@ -0,0 +1,198 @@
<!--
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,
Button,
Card,
CardBody,
CardHeader,
Form,
FormGroup,
FormText,
Icon,
Input,
Label,
Spinner,
} from "@sveltestrap/sveltestrap";
import { putUsersByUid } from "$lib/api-admin";
import { toasts } from "$lib/stores/toasts";
import type { HappydnsUser, HappydnsUserQuota } from "$lib/api-admin";
interface UserQuotaCardProps {
user: HappydnsUser;
uid: string;
}
let { user, uid }: UserQuotaCardProps = $props();
let maxChecksPerDay = $state(0);
let retentionDays = $state(0);
let inactivityPauseDays = $state(0);
let schedulingPaused = $state(false);
let updatedAt = $state<string | undefined>(undefined);
let loading = $state(false);
let errorMessage = $state("");
$effect(() => {
const q: HappydnsUserQuota = user?.quota ?? {};
maxChecksPerDay = q.max_checks_per_day ?? 0;
retentionDays = q.retention_days ?? 0;
inactivityPauseDays = q.inactivity_pause_days ?? 0;
schedulingPaused = q.scheduling_paused ?? false;
updatedAt = q.updated_at;
});
async function handleSubmit(e: Event) {
e.preventDefault();
loading = true;
errorMessage = "";
try {
const body: any = {
email: user.email,
created_at: user.created_at,
last_seen: user.last_seen,
settings: user.settings,
quota: {
max_checks_per_day: Number(maxChecksPerDay) || 0,
retention_days: Number(retentionDays) || 0,
inactivity_pause_days: Number(inactivityPauseDays) || 0,
scheduling_paused: schedulingPaused,
},
};
const res = await putUsersByUid({ path: { uid }, body });
const updated = (res?.data as HappydnsUser | undefined)?.quota;
if (updated?.updated_at) updatedAt = updated.updated_at;
toasts.addToast({
message: "Quota updated successfully",
type: "success",
timeout: 5000,
});
} catch (error) {
errorMessage = "Failed to update quota: " + error;
toasts.addErrorToast({ message: errorMessage, timeout: 10000 });
} finally {
loading = false;
}
}
</script>
<Card class="mb-4">
<CardHeader class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<Icon name="speedometer2" class="me-2"></Icon>
Admin Quota
</h5>
{#if updatedAt}
<small class="text-muted">
Updated {new Date(updatedAt).toLocaleString()}
</small>
{/if}
</CardHeader>
<CardBody>
<p class="text-muted small">
These limits are controlled by administrators and cannot be modified
by the user. A value of <code>0</code> means "use the system default".
</p>
{#if errorMessage}
<Alert color="danger" dismissible fade>{errorMessage}</Alert>
{/if}
<Form on:submit={handleSubmit}>
<FormGroup>
<Label for="schedulingPaused" class="form-check-label">
<Input
type="checkbox"
id="schedulingPaused"
bind:checked={schedulingPaused}
/>
Pause scheduler for this user
</Label>
<FormText>
Admin kill switch — when enabled, no checks will run for this
user regardless of their plans.
</FormText>
</FormGroup>
<FormGroup>
<Label for="retentionDays">Retention (days)</Label>
<Input
type="number"
id="retentionDays"
min="0"
bind:value={retentionDays}
/>
<FormText>
Maximum age of stored check executions. Older entries are
pruned by the janitor according to the tiered retention policy.
</FormText>
</FormGroup>
<FormGroup>
<Label for="maxChecksPerDay">Max checks per day</Label>
<Input
type="number"
id="maxChecksPerDay"
min="0"
bind:value={maxChecksPerDay}
/>
<FormText>
Daily cap on the number of executions the scheduler may launch
for this user (enforced later).
</FormText>
</FormGroup>
<FormGroup>
<Label for="inactivityPauseDays">
Inactivity pause (days)
</Label>
<Input
type="number"
id="inactivityPauseDays"
bind:value={inactivityPauseDays}
/>
<FormText>
The scheduler stops running checks after this many days
without login. Use a negative value to disable.
</FormText>
</FormGroup>
<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 Quota
</Button>
</Form>
</CardBody>
</Card>