Feature: API Tokens (#848)
* initial commit * fix last_used timestamp --------- Co-authored-by: Christian Beutel <>
This commit is contained in:
58
db/main.go
58
db/main.go
@@ -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"},
|
||||
|
||||
138
db/migrations/1772293922_created_api_tokens.go
Normal file
138
db/migrations/1772293922_created_api_tokens.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
88
web/src/lib/components/settings/api_token_modal.svelte
Normal file
88
web/src/lib/components/settings/api_token_modal.svelte
Normal 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
|
||||
>
|
||||
@@ -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
|
||||
>
|
||||
@@ -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 geht’s",
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Пока нет описания",
|
||||
|
||||
@@ -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": "暂无描述",
|
||||
|
||||
11
web/src/lib/models/api/api_token_schema.ts
Normal file
11
web/src/lib/models/api/api_token_schema.ts
Normal 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 }
|
||||
13
web/src/lib/models/api_token.ts
Normal file
13
web/src/lib/models/api_token.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
web/src/lib/stores/api_token_store.ts
Normal file
59
web/src/lib/stores/api_token_store.ts
Normal 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();
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
}
|
||||
|
||||
|
||||
25
web/src/routes/api/v1/api-token/+server.ts
Normal file
25
web/src/routes/api/v1/api-token/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
11
web/src/routes/api/v1/api-token/[id]/+server.ts
Normal file
11
web/src/routes/api/v1/api-token/[id]/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
7
web/src/routes/settings/account/+page.ts
Normal file
7
web/src/routes/settings/account/+page.ts
Normal 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 }
|
||||
};
|
||||
Reference in New Issue
Block a user