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:
parent
c3057abb07
commit
5ec22c6678
4 changed files with 256 additions and 1 deletions
|
|
@ -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
51
model/user_quota.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
198
web-admin/src/routes/users/[uid]/UserQuotaCard.svelte
Normal file
198
web-admin/src/routes/users/[uid]/UserQuotaCard.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue