Feature: API Tokens (#848)

* initial commit

* fix last_used timestamp

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Flomp
2026-03-07 10:10:25 +01:00
committed by GitHub
parent ba88d08078
commit 711a592038
26 changed files with 781 additions and 8 deletions

View File

@@ -136,6 +136,8 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa
app.OnRecordsListRequest("feed", "profile_feed").BindFunc(listFeedHandler())
app.OnRecordCreate("api_tokens").BindFunc(createAPITokenHandler())
app.OnRecordCreateRequest().BindFunc(sanitizeHTML())
app.OnRecordUpdateRequest().BindFunc(sanitizeHTML())
@@ -955,6 +957,22 @@ func listFeedHandler() func(e *core.RecordsListRequestEvent) error {
}
}
func createAPITokenHandler() func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
rawToken := "wanderer_key_" + security.RandomString(32)
hashedKey := security.SHA256(rawToken)
e.Record.Set("token", hashedKey)
// Temporarily store rawToken so we can display it once to the user
e.Record.WithCustomData(true)
e.Record.Set("rawToken", rawToken)
return e.Next()
}
}
func onBeforeServeHandler(client meilisearch.ServiceManager) func(se *core.ServeEvent) error {
return func(se *core.ServeEvent) error {
registerRoutes(se, client)
@@ -1009,6 +1027,46 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
se.Router.POST("/auth/token", func(e *core.RequestEvent) error {
var data struct {
APIToken string `json:"api_token"`
}
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("Failed to read request data", err)
}
hashedAPIToken := security.SHA256(data.APIToken)
tokenRecord, err := e.App.FindFirstRecordByFilter(
"api_tokens",
"token = {:hash}",
map[string]any{"hash": hashedAPIToken},
)
if err != nil {
return apis.NewNotFoundError("Invalid or revoked API token", nil)
}
if !tokenRecord.GetDateTime("expiration").IsZero() &&
tokenRecord.GetDateTime("expiration").Time().Before(time.Now()) {
return apis.NewBadRequestError("Key has expired", nil)
}
tokenRecord.Set("last_used", time.Now())
if err := e.App.Save(tokenRecord); err != nil {
return err
}
userRecord, _ := e.App.FindRecordById("users", tokenRecord.GetString("user"))
token, err := userRecord.NewAuthToken()
if err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{
"token": token,
"record": userRecord,
})
})
se.Router.GET("/search/token", func(e *core.RequestEvent) error {
searchRules := map[string]interface{}{
"lists": map[string]string{"filter": "public = true"},

View File

@@ -0,0 +1,138 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := `{
"createRule": "user = @request.auth.id",
"deleteRule": "user = @request.auth.id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": true,
"id": "text1597481275",
"max": 64,
"min": 64,
"name": "token",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "date617435213",
"max": "",
"min": "",
"name": "expiration",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "date4016875332",
"max": "",
"min": "",
"name": "last_used",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"cascadeDelete": false,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "relation2375276105",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_3525142174",
"indexes": [],
"listRule": "user = @request.auth.id",
"name": "api_tokens",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": "user = @request.auth.id"
}`
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_3525142174")
if err != nil {
return err
}
return app.Delete(collection)
})
}

View File

@@ -54,8 +54,30 @@ const auth: Handle = async ({ event, resolve }) => {
const pb = new PocketBase(envPub.PUBLIC_POCKETBASE_URL)
const url = new URL(event.request.url);
// load the store data from the request cookie string
pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '')
// Handle API token based auth for API requests
if (event.request.headers.has("Authorization") && url.pathname.startsWith("/api")) {
const authHeader = event.request.headers.get("Authorization") as string;
const apiToken = authHeader.replace("Bearer ", "");
if (apiToken.startsWith("wanderer_key")) {
try {
const authData = await pb.send("/auth/token", {
method: "POST",
body: JSON.stringify({
api_token: apiToken
}),
fetch: event.fetch,
})
pb.authStore.save(authData.token, authData.record)
} catch (e) {
throw error(500, "Failed to verify API token " + e)
}
}
} else {
// load the store data from the request cookie string
pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '')
}
const secure = event.url.protocol === "https:"
let meiliCookie = event.cookies.get('meilisearch_token');

View File

@@ -7,6 +7,8 @@
value?: string | Date;
label?: string;
error?: string | string[] | null;
min?: string | number;
max?: string | number;
onchange?: ChangeEventHandler<HTMLInputElement>;
}
@@ -15,7 +17,9 @@
value = $bindable(),
label = "",
error = "",
onchange
min,
max,
onchange,
}: Props = $props();
</script>
@@ -32,6 +36,8 @@
class:border-red-400={(error?.length ?? 0) > 0}
class:bg-input-background-error={(error?.length ?? 0) > 0}
type="date"
{min}
{max}
bind:value
{onchange}
/>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import { APITokenCreateSchema } from "$lib/models/api/api_token_schema";
import type { APIToken } from "$lib/models/api_token";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
import { z } from "zod";
import Datepicker from "../base/datepicker.svelte";
import TextField from "../base/text_field.svelte";
import Toggle from "../base/toggle.svelte";
interface Props {
onsave?: (token: APIToken) => void;
}
let { onsave }: Props = $props();
let modal: Modal;
let expires: boolean = $state(false);
const today = new Date();
today.setDate(today.getDate() + 1);
const tomorrow = today.toISOString().split("T")[0];
export function openModal() {
modal.openModal();
setFields("name", "");
setFields("expiration", undefined);
setErrors("name", []);
setErrors("expiration", []);
}
const { form, errors, setFields, setErrors } = createForm<
z.infer<typeof APITokenCreateSchema>
>({
initialValues: {
name: "",
expiration: "",
},
extend: validator({
schema: APITokenCreateSchema.extend({
user: z.string().optional(),
}),
}),
onSubmit: async (form) => {
onsave?.(form);
modal.closeModal!();
},
});
</script>
<Modal
id="api-token-modal"
size="md:min-w-md"
title={$_("generate-new-token")}
bind:this={modal}
>
{#snippet content()}
<form class="space-y-4" id="api-token-form" use:form>
<TextField label={$_("name")} name="name" error={$errors.name}
></TextField>
<Toggle label={$_("expires")} bind:value={expires}></Toggle>
{#if expires}
<Datepicker
label={$_("expiration")}
name="expiration"
error={$errors.expiration}
min={tomorrow}
></Datepicker>
{/if}
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
type="submit"
form="api-token-form"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import { _ } from "svelte-i18n";
import TextField from "../base/text_field.svelte";
interface Props {
token: string | null;
}
let { token = $bindable() }: Props = $props();
let modal: Modal;
let copied: boolean = $state(false);
export function openModal() {
copied = false;
modal.openModal();
}
export function closeModal() {
token = null;
modal.closeModal();
}
function copyTokenToClipboard() {
if (!token) {
return;
}
navigator.clipboard.writeText(token);
copied = true;
}
</script>
<Modal
id="api-token-success-modal"
size="md:min-w-md"
title={$_("new-token-generated")}
bind:this={modal}
>
{#snippet content()}
<p class="max-w-lg">
Please copy your new API token now. For your security, we cannot
show it to you again. If you lose this token, you will need to
delete it and generate a new one.
</p>
<div class="flex items-center gap-x-4 flex-nowrap">
<div class="flex-1">
<TextField value={token ?? ""} disabled></TextField>
</div>
<button
class="btn-secondary"
onclick={() => copyTokenToClipboard()}
aria-label="copy token to clipboard"
><i class="fa {copied ? 'fa-check' : 'fa-clipboard'}"
></i></button
>
</div>
{/snippet}
{#snippet footer()}
<button class="btn-primary" onclick={() => modal.closeModal()}
>{$_("close")}</button
>
{/snippet}</Modal
>

View File

@@ -24,6 +24,8 @@
"amenity": "",
"ammenity": "Einrichtung",
"api-documentation": "API Dokumentation",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Sehenswürdigkeit",
"author": "Autor",
@@ -139,10 +141,12 @@
"entry": "Eintrag",
"error-copying-trail": "Fehler beim Kopieren der Route",
"error-creating-user": "Fehler beim Erstellen des Nutzers",
"error-deleting-token": "",
"error-disabling-strava-integration": "Fehler beim Deaktivieren der Stravaintegration",
"error-during-login": "Fehler beim Login",
"error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden",
"error-exporting-trail": "Fehler beim Exportieren der Route",
"error-generating-token": "",
"error-liking-trail": "Error liking trail",
"error-logging-in-to-komoot": "Fehler bei der Anmeldung bei komoot",
"error-posting-comment": "Fehler beim Posten des Kommentars",
@@ -155,6 +159,8 @@
"error-updating-strava-integration": "Fehler bei Aktualisierung der komoot-Integration",
"est-duration": "Gesch. Dauer",
"everyone-with-the-link": "Jeder mit dem Link",
"expiration": "",
"expires": "",
"explore": "Erkunden",
"explore-some-trails": "Erkunde einige Routen",
"export": "Exportieren",
@@ -183,6 +189,7 @@
"from-url": "Aus einer URL",
"garage": "Auto Reparatur",
"gas-station": "Tankstelle",
"generate-new-token": "",
"german": "Deutsch",
"get-position-from-exif": "Koordinaten aus EXIF Daten",
"get-started": "Los gehts",
@@ -222,6 +229,7 @@
"keep-original": "",
"keep-private": "Ohne Veröffentlichung fortfahren",
"language": "Sprache",
"last-used": "",
"latitude": "Breitengrad",
"layer": "{n, plural, =1 {Ebene} other {Ebenen}}",
"license": "Lizenz",
@@ -266,13 +274,16 @@
"n-years-ago": "vor {n} Jahren",
"name": "Name",
"near": "Nahe",
"never": "",
"new-list": "Neue Liste",
"new-password": "Neues Passwort",
"new-password-error": "Fehler beim Speichern des Passworts",
"new-password-success": "Neues Passwort wurde gesetzt",
"new-password-text": "Wähle ein neues Passwort",
"new-token-generated": "",
"new-trail": "Neue Route",
"no-account": "Du hast noch kein Konto?",
"no-api-tokens": "",
"no-comments-so-far": "Bisher keine Kommentare",
"no-data": "Keine Daten",
"no-description-for-now": "Noch keine Beschreibung",

View File

@@ -24,6 +24,8 @@
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "API Documentation",
"api-tokens": "API Tokens",
"api-tokens-hint": "API Tokens can be used to grant 3rd party applications access to your wanderer account.",
"apply-user-settings": "Apply user settings",
"attraction": "Attraction",
"author": "Author",
@@ -139,10 +141,12 @@
"entry": "Entry",
"error-copying-trail": "Error copying trail",
"error-creating-user": "Error creating user",
"error-deleting-token": "Error deleting token",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Error during login",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail",
"error-generating-token": "Error generating token",
"error-liking-trail": "Error liking trail",
"error-logging-in-to-komoot": "Error logging in to komoot",
"error-posting-comment": "Error posting comment",
@@ -155,6 +159,8 @@
"error-updating-strava-integration": "Error updating komoot integration",
"est-duration": "Est. duration",
"everyone-with-the-link": "Everyone with the link",
"expiration": "Expiration",
"expires": "Expires",
"explore": "Explore",
"explore-some-trails": "Explore some trails",
"export": "Export",
@@ -183,6 +189,7 @@
"from-url": "From URL",
"garage": "Garage",
"gas-station": "Gas station",
"generate-new-token": "Generate new token",
"german": "German",
"get-position-from-exif": "Get coordinates from EXIF data",
"get-started": "Get started",
@@ -222,6 +229,7 @@
"keep-original": "Keep original",
"keep-private": "Keep private",
"language": "Language",
"last-used": "Last used",
"latitude": "Latitude",
"layer": "{n, plural, =1 {Layer} other {Layers}}",
"license": "License",
@@ -266,13 +274,16 @@
"n-years-ago": "{n} years ago",
"name": "Name",
"near": "Near",
"never": "Never",
"new-list": "New List",
"new-password": "New password",
"new-password-error": "Error setting new password",
"new-password-success": "The new password has been set",
"new-password-text": "Set a new password",
"new-token-generated": "New API Token generated",
"new-trail": "New Trail",
"no-account": "Don't have an account?",
"no-api-tokens": "You have no API Tokens",
"no-comments-so-far": "No comments so far",
"no-data": "No data",
"no-description-for-now": "No description for now",

View File

@@ -24,6 +24,8 @@
"amenity": "",
"ammenity": "Servicios",
"api-documentation": "Documentación API",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Atracción",
"author": "Autor",
@@ -37,6 +39,7 @@
"basic-info": "Información básica",
"basque": "Vasco",
"before": "Antes",
"behavior": "",
"bicycle-parking": "Aparcamiento de bicicletas",
"bicycle-rental": "Alquiler de bicicletas",
"bicycle-shop": "Tienda de bicicletas",
@@ -138,10 +141,12 @@
"entry": "Entrada",
"error-copying-trail": "",
"error-creating-user": "Error creando el usuario",
"error-deleting-token": "",
"error-disabling-strava-integration": "Error al desactivar la integración de strava",
"error-during-login": "Error durante el acceso",
"error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico",
"error-exporting-trail": "Error exportando la ruta",
"error-generating-token": "",
"error-liking-trail": "Error al dar \"me gusta\" a la ruta",
"error-logging-in-to-komoot": "Error al iniciar sesión en komoot",
"error-posting-comment": "Error publicando el comentario",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "Error al actualizar la integración con komoot",
"est-duration": "Duración estimada",
"everyone-with-the-link": "Cualquier persona con el enlace",
"expiration": "",
"expires": "",
"explore": "Explora",
"explore-some-trails": "Explora alguna ruta",
"export": "Exportar",
@@ -182,6 +189,7 @@
"from-url": "Desde URL",
"garage": "Garaje",
"gas-station": "Gasolinera",
"generate-new-token": "",
"german": "Alemán",
"get-position-from-exif": "Obtener las coordenadas de los datos EXIF",
"get-started": "Iniciar",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "Idioma",
"last-used": "",
"latitude": "Latitud",
"layer": "{n, plural, one {}=1 {Lista} other {Listas}}",
"license": "Licencia",
@@ -265,13 +274,16 @@
"n-years-ago": "hace {n} años",
"name": "Nombre",
"near": "Cerca",
"never": "",
"new-list": "Nueva lista",
"new-password": "Nueva contraseña",
"new-password-error": "Error estableciendo la nueva contraseña",
"new-password-success": "La nueva contraseña ha sido configurada",
"new-password-text": "Configura una nueva contraseña",
"new-token-generated": "",
"new-trail": "Nueva Ruta",
"no-account": "¿No tienes una cuenta?",
"no-api-tokens": "",
"no-comments-so-far": "Ningún comentario todavía",
"no-data": "No datos",
"no-description-for-now": "Ninguna descripción de momento",

View File

@@ -24,6 +24,8 @@
"amenity": "Altimetria",
"ammenity": "",
"api-documentation": "API dokumentazioa",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Erakarmena",
"author": "Egilea",
@@ -37,6 +39,7 @@
"basic-info": "Oinarrizko informazioa",
"basque": "Euskara",
"before": "Aurretik",
"behavior": "",
"bicycle-parking": "Bizikleta-parkina",
"bicycle-rental": "Bizikleta-alokairua",
"bicycle-shop": "Bizikleta-denda",
@@ -138,10 +141,12 @@
"entry": "Sarrera",
"error-copying-trail": "",
"error-creating-user": "Errorea erabiltzailea sortzen",
"error-deleting-token": "",
"error-disabling-strava-integration": "Errorea stravarekin integrazioa desaktibatzean",
"error-during-login": "Errorea sartzean",
"error-during-password-reset": "Ezin izan da pasahitza berrezartzeko mezua bidali",
"error-exporting-trail": "Errorea ibilbidea esportatzean",
"error-generating-token": "",
"error-liking-trail": "Errorea ibilbidea atsegitean",
"error-logging-in-to-komoot": "Errorea komoot-en login egitean",
"error-posting-comment": "Errorea iruzkina egitean",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "Errorea komoot integrazioa eguneratzean",
"est-duration": "Ustezko iraupena",
"everyone-with-the-link": "Esteka duen edonor",
"expiration": "",
"expires": "",
"explore": "Arakatu",
"explore-some-trails": "Arakatu ibilbide batzuk",
"export": "Esportatu",
@@ -182,6 +189,7 @@
"from-url": "URL batetik",
"garage": "Garajea",
"gas-station": "Gasolindegia",
"generate-new-token": "",
"german": "Alemaniera",
"get-position-from-exif": "Lortu koordenatuak EXIF datuetatik",
"get-started": "Hasi",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "Hizkuntza",
"last-used": "",
"latitude": "Latitudea",
"layer": "{n, plural, one {}=1 {geruza} other {geruza}}",
"license": "Lizentzia",
@@ -265,13 +274,16 @@
"n-years-ago": "Orain dela {n} urte",
"name": "Izena",
"near": "Gertu",
"never": "",
"new-list": "Zerrenda berria",
"new-password": "Pasahitz berria",
"new-password-error": "Errorea pasahitza berria ezartzean",
"new-password-success": "Pasahitz berria ezarri da",
"new-password-text": "Pasahitz berria sortu",
"new-token-generated": "",
"new-trail": "Ibilaldi berria",
"no-account": "Ez duzu konturik?",
"no-api-tokens": "",
"no-comments-so-far": "Ez dago iruzkinik",
"no-data": "Ez dago daturik",
"no-description-for-now": "Ez dago deskribapenik",

View File

@@ -24,6 +24,8 @@
"amenity": "",
"ammenity": "Aménagement",
"api-documentation": "Documentation API",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Attraction",
"author": "Auteur",
@@ -37,6 +39,7 @@
"basic-info": "Informations de base",
"basque": "Basque",
"before": "Avant le",
"behavior": "",
"bicycle-parking": "Parking vélo",
"bicycle-rental": "Location de vélos",
"bicycle-shop": "Magasin de vélos",
@@ -138,10 +141,12 @@
"entry": "Entrée",
"error-copying-trail": "",
"error-creating-user": "Erreur durant la création de l'utilisateur",
"error-deleting-token": "",
"error-disabling-strava-integration": "Erreur lors de la désactivation de l'intégration Strava",
"error-during-login": "Erreur durant la connexion",
"error-during-password-reset": "Impossible d'envoyer l'e-mail de réinitialisation du mot de passe",
"error-exporting-trail": "Erreur lors de l'export de l'itinéraire",
"error-generating-token": "",
"error-liking-trail": "Erreur de like de l'itinéraire",
"error-logging-in-to-komoot": "Erreur de connexion à Komoot",
"error-posting-comment": "Erreur lors de la publication du commentaire",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "Erreur lors de la mise à jour de l'intégration Komoot",
"est-duration": "Temps estimé",
"everyone-with-the-link": "Tout le monde avec ce lien",
"expiration": "",
"expires": "",
"explore": "Explorer",
"explore-some-trails": "Explorer les itinéraires",
"export": "Exporter",
@@ -182,6 +189,7 @@
"from-url": "Depuis une URL",
"garage": "Garage",
"gas-station": "Station-service",
"generate-new-token": "",
"german": "Allemand",
"get-position-from-exif": "Obtenir les coordonnées à partir des données EXIF",
"get-started": "C'est parti",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "Langue",
"last-used": "",
"latitude": "Latitude",
"layer": "{n, plural, =1 {Calque} other {Calques}}",
"license": "Licence",
@@ -265,13 +274,16 @@
"n-years-ago": "il y a {n} ans",
"name": "Nom de l'itinéraire",
"near": "À proximité de",
"never": "",
"new-list": "Nouvelle liste",
"new-password": "Nouveau mot de passe",
"new-password-error": "Erreur lors de l'enregistrement du nouveau mot de passe",
"new-password-success": "Le nouveau mot de passe a été enregistré",
"new-password-text": "Définir un nouveau mot de passe",
"new-token-generated": "",
"new-trail": "Nouvel itinéraire",
"no-account": "Pas encore de compte ?",
"no-api-tokens": "",
"no-comments-so-far": "Aucun commentaire pour l'instant",
"no-data": "Pas de données",
"no-description-for-now": "Pas de description pour le moment",

View File

@@ -24,6 +24,8 @@
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "API Dokumentáció",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Attraction",
"author": "Author",
@@ -37,6 +39,7 @@
"basic-info": "Alap információk",
"basque": "Basque",
"before": "Before",
"behavior": "",
"bicycle-parking": "Bicycle Parking",
"bicycle-rental": "Bicycle Rental",
"bicycle-shop": "Bicycle Shop",
@@ -138,10 +141,12 @@
"entry": "Bejegyzés",
"error-copying-trail": "",
"error-creating-user": "Hiba felhasználó hozzáadása közben",
"error-deleting-token": "",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Hiba bejelentkezés közben",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail",
"error-generating-token": "",
"error-liking-trail": "Error liking trail",
"error-logging-in-to-komoot": "Error logging in to komoot",
"error-posting-comment": "Error posting comment",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "Error updating komoot integration",
"est-duration": "Becsült időtartam",
"everyone-with-the-link": "Everyone with the link",
"expiration": "",
"expires": "",
"explore": "Felfedezés",
"explore-some-trails": "Fedezzen fel néhány ösvényt",
"export": "Export",
@@ -182,6 +189,7 @@
"from-url": "From URL",
"garage": "Garage",
"gas-station": "Gas station",
"generate-new-token": "",
"german": "Német",
"get-position-from-exif": "Get coordinates from EXIF data",
"get-started": "Get started",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "Nyelf",
"last-used": "",
"latitude": "Szélesség",
"layer": "{n, plural, =1 {Layer} other {Layers}}",
"license": "License",
@@ -265,13 +274,16 @@
"n-years-ago": "{n} years ago",
"name": "Név",
"near": "Közelben",
"never": "",
"new-list": "Új lista",
"new-password": "New password",
"new-password-error": "Error setting new password",
"new-password-success": "The new password has been set",
"new-password-text": "Set a new password",
"new-token-generated": "",
"new-trail": "Új útvonal",
"no-account": "Nincs még fiókja?",
"no-api-tokens": "",
"no-comments-so-far": "No comments so far",
"no-data": "No data",
"no-description-for-now": "No description for now",

View File

@@ -24,6 +24,8 @@
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "Documentazione API",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Attraction",
"author": "Autore",
@@ -37,6 +39,7 @@
"basic-info": "Informazioni di base",
"basque": "Basque",
"before": "Prima",
"behavior": "",
"bicycle-parking": "Bicycle Parking",
"bicycle-rental": "Bicycle Rental",
"bicycle-shop": "Bicycle Shop",
@@ -138,10 +141,12 @@
"entry": "Voce",
"error-copying-trail": "",
"error-creating-user": "Errore nella creazione dell'utente",
"error-deleting-token": "",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Errore durante il login",
"error-during-password-reset": "Impossibile inviare email per ripristinare la password",
"error-exporting-trail": "Errore durante l'esportazione del percorso",
"error-generating-token": "",
"error-liking-trail": "Error liking trail",
"error-logging-in-to-komoot": "Error logging in to komoot",
"error-posting-comment": "Errore pubblicando il commento",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "Error updating komoot integration",
"est-duration": "Durata stimata",
"everyone-with-the-link": "Everyone with the link",
"expiration": "",
"expires": "",
"explore": "Esplora",
"explore-some-trails": "Esplora alcuni percorsi",
"export": "Esporta",
@@ -182,6 +189,7 @@
"from-url": "From URL",
"garage": "Garage",
"gas-station": "Gas station",
"generate-new-token": "",
"german": "Tedesco",
"get-position-from-exif": "Ottieni posizione da dati EXIF",
"get-started": "Get started",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "Lingua",
"last-used": "",
"latitude": "Latitudine",
"layer": "{n, plural, =1 {Layer} other {Layers}}",
"license": "Licenza",
@@ -265,13 +274,16 @@
"n-years-ago": "{n} anni fa",
"name": "Nome",
"near": "Vicino",
"never": "",
"new-list": "Nuova lista",
"new-password": "Nuova password",
"new-password-error": "Errore configurando la nuova password",
"new-password-success": "La nuova password è stata configurata",
"new-password-text": "Definire una nuova password",
"new-token-generated": "",
"new-trail": "Nuovo percorso",
"no-account": "Non hai ancora un account?",
"no-api-tokens": "",
"no-comments-so-far": "Nessun commento per il momento",
"no-data": "Nessun dato",
"no-description-for-now": "Nessuna descrizione per il momento",

View File

@@ -24,6 +24,8 @@
"amenity": "Amenity",
"ammenity": "Voorziening",
"api-documentation": "API-documentatie",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Attractie",
"author": "Auteur",
@@ -37,6 +39,7 @@
"basic-info": "Algemene informatie",
"basque": "Baskisch",
"before": "Voor",
"behavior": "",
"bicycle-parking": "Fietsenstalling",
"bicycle-rental": "Fietsverhuur",
"bicycle-shop": "Fietsenwinkel",
@@ -138,10 +141,12 @@
"entry": "Item",
"error-copying-trail": "",
"error-creating-user": "Fout bij aanmaken gebruiker",
"error-deleting-token": "",
"error-disabling-strava-integration": "Fout bij het uitschakelen van Strava-integratie",
"error-during-login": "Het inloggen is mislukt",
"error-during-password-reset": "Kan geen e-mail voor wachtwoordherstel verzenden",
"error-exporting-trail": "Fout bij exporteren van parcours",
"error-generating-token": "",
"error-liking-trail": "Fout bij het \"leuk vinden\" van route",
"error-logging-in-to-komoot": "Fout tijdens inloggen in Komoot",
"error-posting-comment": "Fout bij het plaatsen van een reactie",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "Fout bij bijwerken van Komoot integratie",
"est-duration": "Geschatte duur",
"everyone-with-the-link": "Iedereen met de link",
"expiration": "",
"expires": "",
"explore": "Verkennen",
"explore-some-trails": "Verken enkele routes",
"export": "Exporteer",
@@ -182,6 +189,7 @@
"from-url": "Van URL",
"garage": "Garage",
"gas-station": "Benzinestation",
"generate-new-token": "",
"german": "Duits",
"get-position-from-exif": "Coördinaten ophalen uit EXIF-gegevens",
"get-started": "Aan de slag",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "Taal",
"last-used": "",
"latitude": "Breedtegraad",
"layer": "{n, plural, =1 {Layer} other {Layers}}",
"license": "Licentie",
@@ -265,13 +274,16 @@
"n-years-ago": "{n} jaren geleden",
"name": "Naam",
"near": "Nabij",
"never": "",
"new-list": "Nieuwe lijst",
"new-password": "Nieuw wachtwoord",
"new-password-error": "Fout bij het instellen van een nieuw wachtwoord",
"new-password-success": "Het nieuwe wachtwoord is ingesteld.",
"new-password-text": "Stel nieuw wachtwoord in",
"new-token-generated": "",
"new-trail": "Nieuwe Route",
"no-account": "Heb je nog geen account?",
"no-api-tokens": "",
"no-comments-so-far": "Tot nu toe geen opmerkingen",
"no-data": "Geen data",
"no-description-for-now": "Voorlopig geen beschrijving",

View File

@@ -24,6 +24,8 @@
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "Dokumentacja API",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Attraction",
"author": "Autor",
@@ -37,6 +39,7 @@
"basic-info": "Podstawowe informacje",
"basque": "Basque",
"before": "Przed",
"behavior": "",
"bicycle-parking": "Bicycle Parking",
"bicycle-rental": "Bicycle Rental",
"bicycle-shop": "Bicycle Shop",
@@ -138,10 +141,12 @@
"entry": "Pozycja",
"error-copying-trail": "",
"error-creating-user": "Błąd tworzenia użytkownika",
"error-deleting-token": "",
"error-disabling-strava-integration": "Błąd przy wyłączaniu integracji strava",
"error-during-login": "Błąd podczas logowania",
"error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła",
"error-exporting-trail": "Błąd podczas eksportowania szlaku",
"error-generating-token": "",
"error-liking-trail": "Error liking trail",
"error-logging-in-to-komoot": "Błąd zapisu do komoot",
"error-posting-comment": "Błąd wysyłania komentarza",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "Błąd aktualizacji integracji kamoot",
"est-duration": "Szacowany czas",
"everyone-with-the-link": "Everyone with the link",
"expiration": "",
"expires": "",
"explore": "Eksploruj",
"explore-some-trails": "Eksploruj różne szlaki",
"export": "Eksportuj",
@@ -182,6 +189,7 @@
"from-url": "Z URL",
"garage": "Garage",
"gas-station": "Gas station",
"generate-new-token": "",
"german": "Niemiecki",
"get-position-from-exif": "Odczytaj współrzędne z danych EXIF",
"get-started": "Get started",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "Język",
"last-used": "",
"latitude": "Szerokość",
"layer": "{n, plural, =1 {Layer} other {Layers}}",
"license": "Licencja",
@@ -265,13 +274,16 @@
"n-years-ago": "{n} lat temu",
"name": "Nazwa",
"near": "Blisko",
"never": "",
"new-list": "Nowa Lista",
"new-password": "Nowe hasło",
"new-password-error": "Błąd ustawiania nowego hasła",
"new-password-success": "Nowe hasło zostało ustawione",
"new-password-text": "Ustaw nowe hasło",
"new-token-generated": "",
"new-trail": "Nowy szlak",
"no-account": "Nie masz konta?",
"no-api-tokens": "",
"no-comments-so-far": "Nie ma jeszcze komentarzy",
"no-data": "Brak danych",
"no-description-for-now": "Nie ma jeszcze opisu",

View File

@@ -24,6 +24,8 @@
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "Documentação da API",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Attraction",
"author": "Author",
@@ -37,6 +39,7 @@
"basic-info": "Informações básicas",
"basque": "Basque",
"before": "Antes",
"behavior": "",
"bicycle-parking": "Bicycle Parking",
"bicycle-rental": "Bicycle Rental",
"bicycle-shop": "Bicycle Shop",
@@ -138,10 +141,12 @@
"entry": "Entrada",
"error-copying-trail": "",
"error-creating-user": "Erro ao criar utilizador",
"error-deleting-token": "",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Erro durante o login",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Erro na exportação do percurso",
"error-generating-token": "",
"error-liking-trail": "Error liking trail",
"error-logging-in-to-komoot": "Error logging in to komoot",
"error-posting-comment": "Error posting comment",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "Error updating komoot integration",
"est-duration": "Duração prevista",
"everyone-with-the-link": "Everyone with the link",
"expiration": "",
"expires": "",
"explore": "Explorar",
"explore-some-trails": "Explore algumas trilhas",
"export": "Exportar",
@@ -182,6 +189,7 @@
"from-url": "From URL",
"garage": "Garage",
"gas-station": "Gas station",
"generate-new-token": "",
"german": "Alemão",
"get-position-from-exif": "Obter coordenadas dos dados EXIF",
"get-started": "Get started",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "Língua",
"last-used": "",
"latitude": "Latitude",
"layer": "{n, plural, =1 {Layer} other {Layers}}",
"license": "Licença",
@@ -265,13 +274,16 @@
"n-years-ago": "{n} anos atrás",
"name": "Nome",
"near": "Próximo",
"never": "",
"new-list": "Nova lista",
"new-password": "Nova senha",
"new-password-error": "Error setting new password",
"new-password-success": "The new password has been set",
"new-password-text": "Set a new password",
"new-token-generated": "",
"new-trail": "Nova trilha",
"no-account": "Não tem uma conta?",
"no-api-tokens": "",
"no-comments-so-far": "No comments so far",
"no-data": "Sem dados",
"no-description-for-now": "No description for now",

View File

@@ -24,6 +24,8 @@
"amenity": "Amenity",
"ammenity": "",
"api-documentation": "Документация API",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "Attraction",
"author": "Автор",
@@ -37,6 +39,7 @@
"basic-info": "Основная информация",
"basque": "Basque",
"before": "До",
"behavior": "",
"bicycle-parking": "Велосипедная парковка",
"bicycle-rental": "Прокат велосипедов",
"bicycle-shop": "Веломагазин",
@@ -138,10 +141,12 @@
"entry": "Запись",
"error-copying-trail": "",
"error-creating-user": "Ошибка создания пользователя",
"error-deleting-token": "",
"error-disabling-strava-integration": "Ошибка отключения Strava",
"error-during-login": "Ошибка входа",
"error-during-password-reset": "Не удалось отправить email сброса пароля",
"error-exporting-trail": "Ошибка экспорта трека",
"error-generating-token": "",
"error-liking-trail": "Error liking trail",
"error-logging-in-to-komoot": "Ошибка входа в Komoot",
"error-posting-comment": "Ошибка отправки комментария",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "Ошибка обновления Strava",
"est-duration": "Продолжительность",
"everyone-with-the-link": "Everyone with the link",
"expiration": "",
"expires": "",
"explore": "Изучить",
"explore-some-trails": "Изучите треки",
"export": "Экспорт",
@@ -182,6 +189,7 @@
"from-url": "По ссылке",
"garage": "Гараж",
"gas-station": "Gas station",
"generate-new-token": "",
"german": "Немецкий",
"get-position-from-exif": "Координаты из EXIF",
"get-started": "Get started",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "Язык",
"last-used": "",
"latitude": "Широта",
"layer": "{n, plural, =1 {Layer} other {Layers}}",
"license": "Лицензия",
@@ -265,13 +274,16 @@
"n-years-ago": "{n} лет назад",
"name": "Название",
"near": "Рядом",
"never": "",
"new-list": "Новый список",
"new-password": "Новый пароль",
"new-password-error": "Ошибка обновления пароля",
"new-password-success": "Пароль успешно изменен",
"new-password-text": "Установить новый пароль",
"new-token-generated": "",
"new-trail": "Новый трек",
"no-account": "Нет аккаунта?",
"no-api-tokens": "",
"no-comments-so-far": "Пока нет комментариев",
"no-data": "Нет данных",
"no-description-for-now": "Пока нет описания",

View File

@@ -24,6 +24,8 @@
"amenity": "友好性",
"ammenity": "",
"api-documentation": "API 文档",
"api-tokens": "",
"api-tokens-hint": "",
"apply-user-settings": "",
"attraction": "景点",
"author": "作者",
@@ -37,6 +39,7 @@
"basic-info": "基本信息",
"basque": "Basque",
"before": "之前",
"behavior": "",
"bicycle-parking": "自行车停车场",
"bicycle-rental": "自行车租车",
"bicycle-shop": "自行车店",
@@ -138,10 +141,12 @@
"entry": "日程",
"error-copying-trail": "",
"error-creating-user": "创建用户错误",
"error-deleting-token": "",
"error-disabling-strava-integration": "禁用strava集成时出错",
"error-during-login": "登录错误",
"error-during-password-reset": "无法发送密码重置邮件",
"error-exporting-trail": "导出路线失败",
"error-generating-token": "",
"error-liking-trail": "赞轨迹时出错",
"error-logging-in-to-komoot": "登录到 komoot 时出错",
"error-posting-comment": "发布评论时出错",
@@ -154,6 +159,8 @@
"error-updating-strava-integration": "更新 komoot 集成出错",
"est-duration": "预计时长",
"everyone-with-the-link": "Everyone with the link",
"expiration": "",
"expires": "",
"explore": "探索",
"explore-some-trails": "探索行程",
"export": "导出",
@@ -182,6 +189,7 @@
"from-url": "从 URL",
"garage": "车库",
"gas-station": "加油站",
"generate-new-token": "",
"german": "德语",
"get-position-from-exif": "从EXIF数据获取坐标",
"get-started": "Get started",
@@ -221,6 +229,7 @@
"keep-original": "",
"keep-private": "Keep private",
"language": "语言",
"last-used": "",
"latitude": "纬度",
"layer": "{n, plural, =1 {层} other {层}}",
"license": "开源协议",
@@ -265,13 +274,16 @@
"n-years-ago": "{n} 年前",
"name": "名称",
"near": "附近",
"never": "",
"new-list": "新列表",
"new-password": "新密码",
"new-password-error": "设置新密码时出错",
"new-password-success": "新密码已设置",
"new-password-text": "设置新密码",
"new-token-generated": "",
"new-trail": "创建新路线",
"no-account": "还未注册?",
"no-api-tokens": "",
"no-comments-so-far": "到目前为止没有评论",
"no-data": "无数据",
"no-description-for-now": "暂无描述",

View File

@@ -0,0 +1,11 @@
import { z, ZodType } from "zod";
import type { APIToken } from "../api_token";
const APITokenCreateSchema = z.object({
name: z.string().min(1, "required"),
expiration: z.string().refine((val) => !isNaN(Date.parse(val)), "invalid-date").refine((val) => Date.parse(val) > new Date().getTime(), "date-in-past").optional(),
user: z.string().length(15),
}) satisfies ZodType<APIToken>
export { APITokenCreateSchema }

View File

@@ -0,0 +1,13 @@
export class APIToken {
id?: string;
name: string;
expiration?: string;
last_used?: string;
user?: string;
constructor(name: string, expiration?: string) {
this.name = name;
this.expiration = expiration;
}
}

View File

@@ -0,0 +1,59 @@
import { get } from "svelte/store";
import { currentUser } from "./user_store";
import type { APIToken } from "$lib/models/api_token";
import { APIError } from "$lib/util/api_util";
import type { ListResult } from "pocketbase";
export async function api_tokens_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/api-token`, {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const fetchedTokens: ListResult<APIToken> = await r.json();
return fetchedTokens;
}
export async function api_tokens_create(apiToken: APIToken) {
const user = get(currentUser)
if (!user) {
throw Error("Unauthenticated")
}
apiToken.user = user.id
let r = await fetch('/api/v1/api-token', {
method: 'PUT',
body: JSON.stringify(apiToken),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const model: APIToken & { rawToken: string } = await r.json();
return model;
}
export async function api_tokens_delete(token: APIToken) {
const r = await fetch('/api/v1/api-token/' + token.id, {
method: 'DELETE',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
return await r.json();
}

View File

@@ -41,6 +41,7 @@ export enum Collection {
trails_bounding_box = "trails_bounding_box",
trails_filter = "trails_filter",
users_anonymous = "users_anonymous",
api_tokens = "api_tokens"
}

View File

@@ -0,0 +1,25 @@
import type { APIToken } from '$lib/models/api_token';
import { create } from '$lib/util/api_util';
import { Collection, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import { APITokenCreateSchema } from "$lib/models/api/api_token_schema";
export async function GET(event: RequestEvent) {
try {
const r = await list<APIToken>(event, Collection.api_tokens);
return json(r)
} catch (e: any) {
return handleError(e);
}
}
export async function PUT(event: RequestEvent) {
try {
const r = await create<APIToken>(event, APITokenCreateSchema, Collection.api_tokens)
return json(r);
} catch (e) {
return handleError(e)
}
}

View File

@@ -0,0 +1,11 @@
import { Collection, handleError, remove } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function DELETE(event: RequestEvent) {
try {
const r = await remove(event, Collection.api_tokens)
return json(r);
} catch (e: any) {
return handleError(e)
}
}

View File

@@ -1,9 +1,17 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import Button from "$lib/components/base/button.svelte";
import ConfirmModal from "$lib/components/confirm_modal.svelte";
import ApiTokenModal from "$lib/components/settings/api_token_modal.svelte";
import ApiTokenSuccessModal from "$lib/components/settings/api_token_success_modal.svelte";
import EmailModal from "$lib/components/settings/email_modal.svelte";
import PasswordModal from "$lib/components/settings/password_modal.svelte";
import type { APIToken } from "$lib/models/api_token";
import {
api_tokens_create,
api_tokens_delete,
} from "$lib/stores/api_token_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
import {
currentUser,
@@ -14,6 +22,8 @@
import { onMount } from "svelte";
import { _ } from "svelte-i18n";
let { data } = $props();
const settings = page.data.settings;
let selectedLanguage = "en";
@@ -24,6 +34,11 @@
let confirmModal: ConfirmModal;
let emailModal: EmailModal;
let passwordModal: PasswordModal;
let tokenModal: ApiTokenModal;
let tokenSuccessModal: ApiTokenSuccessModal;
let tokenLoading: boolean = $state(false);
let rawAPIToken: string | null = $state(null);
onMount(() => {
citySearchQuery = settings?.location?.name ?? "";
@@ -74,6 +89,37 @@
});
}
}
async function generateAPIToken(token: APIToken) {
try {
tokenLoading = true;
const tokenResponse = await api_tokens_create(token);
rawAPIToken = tokenResponse.rawToken;
tokenSuccessModal.openModal();
await invalidateAll();
} catch (e) {
show_toast({
text: $_("error-generating-token"),
icon: "close",
type: "error",
});
} finally {
tokenLoading = false;
}
}
async function deleteAPIToken(token: APIToken) {
try {
const tokenResponse = await api_tokens_delete(token);
await invalidateAll();
} catch (e) {
show_toast({
text: $_("error-deleting-token"),
icon: "close",
type: "error",
});
}
}
</script>
<svelte:head>
@@ -91,6 +137,79 @@
<button class="btn-secondary" onclick={() => passwordModal.openModal()}
>{$_("change-password")}</button
>
<div>
<div class="flex justify-between items-center">
<h4 class="text-xl font-medium">{$_("api-tokens")}</h4>
<Button
secondary
onclick={() => tokenModal.openModal()}
loading={tokenLoading}
><i class="fa fa-plus mr-2"></i>
{$_("generate-new-token")}</Button
>
</div>
<p class="mt-3">{$_("api-tokens-hint")}</p>
{#if data.apiTokens.totalItems == 0}
<p class="text-center pt-8 pb-2 text-gray-500">
{$_("no-api-tokens")}
</p>
{:else}
<div
class="border border-input-border rounded-xl overflow-clip mt-4"
>
<table class="api-token-table w-full table-auto">
<thead class="text-left">
<tr class="text-sm bg-secondary-hover">
<th>{$_("name")}</th>
<th>{$_("expiration")}</th>
<th>{$_("last-used")}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each data.apiTokens.items as token}
<tr class="border-t border-input-border">
<td>{token.name}</td>
<td
>{token.expiration
? new Date(
token.expiration,
).toLocaleDateString(undefined, {
month: "long",
day: "2-digit",
year: "numeric",
timeZone: "UTC",
})
: $_("never")}</td
>
<td
>{token.last_used
? new Date(
token.last_used,
).toLocaleTimeString(undefined, {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "-"}</td
>
<td
><button
onclick={() =>
deleteAPIToken(token)}
aria-label="delete api token"
><i class="fa fa-trash"></i></button
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="space-y-4">
<h4 class="text-xl text-red-400 font-medium">
{$_("danger-zone")}
@@ -108,13 +227,22 @@
onsave={updateEmail}
bind:this={emailModal}
></EmailModal>
<PasswordModal
onsave={updatePassword}
bind:this={passwordModal}
<PasswordModal onsave={updatePassword} bind:this={passwordModal}
></PasswordModal>
<ApiTokenModal onsave={generateAPIToken} bind:this={tokenModal}
></ApiTokenModal>
<ApiTokenSuccessModal bind:token={rawAPIToken} bind:this={tokenSuccessModal}
></ApiTokenSuccessModal>
{/if}
<ConfirmModal
text={$_("account-delete-confirm")}
bind:this={confirmModal}
onconfirm={deleteAccount}
></ConfirmModal>
<style>
.api-token-table th,
td {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,7 @@
import { api_tokens_index } from "$lib/stores/api_token_store";
import { type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch }) => {
const apiTokens = await api_tokens_index(fetch)
return { apiTokens: apiTokens }
};