diff --git a/server/auth/authenticator.go b/server/auth/authenticator.go index bf0b3b77..c3208b18 100644 --- a/server/auth/authenticator.go +++ b/server/auth/authenticator.go @@ -145,7 +145,8 @@ type bearerAuth struct { // - (nil, err) on an unexpected store error; // - (result, nil) on success. // -// It performs no side effects; callers decide whether to record PAT usage. +// Successful PAT resolution records the token's last-used time, so every entry +// point that authenticates through here gets usage tracking for free. func (a *Authenticator) resolveBearer(ctx context.Context, token string) (*bearerAuth, error) { if token == "" { return nil, nil @@ -169,6 +170,7 @@ func (a *Authenticator) resolveBearer(ctx context.Context, token string) (*beare // Personal Access Token. if user, pat, err := a.AuthenticateByPAT(ctx, token); err == nil && user != nil { + a.recordPATUsage(user.ID, pat.TokenId) return &bearerAuth{user: user, pat: pat}, nil } return nil, nil @@ -209,7 +211,7 @@ func (a *Authenticator) AuthenticateToUser(ctx context.Context, authHeader, cook // Authenticate resolves a Bearer token (Access Token V2 or PAT) into an AuthResult, // returning nil when no valid credentials are present. Unlike AuthenticateToUser it -// ignores the refresh cookie, and it records PAT last-used on success. +// ignores the refresh cookie. func (a *Authenticator) Authenticate(ctx context.Context, authHeader string) *AuthResult { token := ExtractBearerToken(authHeader) bearer, err := a.resolveBearer(ctx, token) @@ -217,7 +219,6 @@ func (a *Authenticator) Authenticate(ctx context.Context, authHeader string) *Au return nil } if bearer.pat != nil { - a.recordPATUsage(bearer.user.ID, bearer.pat.TokenId) return &AuthResult{User: bearer.user, AccessToken: token} } return &AuthResult{Claims: bearer.claims, AccessToken: token} diff --git a/server/router/api/v1/test/auth_test.go b/server/router/api/v1/test/auth_test.go index ee225cfa..6e595240 100644 --- a/server/router/api/v1/test/auth_test.go +++ b/server/router/api/v1/test/auth_test.go @@ -261,6 +261,35 @@ func TestAuthenticatorPAT(t *testing.T) { assert.Equal(t, tokenID, pat.TokenId) }) + t.Run("records last used time for user authentication", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateRegularUser(ctx, "pat-last-used") + require.NoError(t, err) + + token := auth.GeneratePersonalAccessToken() + tokenID := util.GenUUID() + err = ts.Store.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: tokenID, + TokenHash: auth.HashPersonalAccessToken(token), + CreatedAt: timestamppb.Now(), + }) + require.NoError(t, err) + + authenticatedAt := time.Now() + authenticator := auth.NewAuthenticator(ts.Store, ts.Secret) + authenticatedUser, err := authenticator.AuthenticateToUser(ctx, "Bearer "+token, "") + require.NoError(t, err) + require.NotNil(t, authenticatedUser) + require.Equal(t, user.ID, authenticatedUser.ID) + + require.Eventually(t, func() bool { + pats, err := ts.Store.GetUserPersonalAccessTokens(ctx, user.ID) + return err == nil && len(pats) == 1 && pats[0].LastUsedAt != nil && !pats[0].LastUsedAt.AsTime().Before(authenticatedAt) + }, time.Second, 10*time.Millisecond) + }) + t.Run("fails with invalid PAT format", func(t *testing.T) { ts := NewTestService(t) defer ts.Cleanup() diff --git a/store/store.go b/store/store.go index 12b06bc9..028f65df 100644 --- a/store/store.go +++ b/store/store.go @@ -16,6 +16,7 @@ type Store struct { userCreateMu sync.Mutex authConfigMu sync.Mutex + patMu sync.Mutex deploymentConfigMu sync.RWMutex deploymentConfig *deploymentConfiguration diff --git a/store/test/user_setting_test.go b/store/test/user_setting_test.go index 58f6f247..1c634b36 100644 --- a/store/test/user_setting_test.go +++ b/store/test/user_setting_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" @@ -587,6 +588,14 @@ func TestUserSettingUpdatePATLastUsed(t *testing.T) { require.NoError(t, err) require.Len(t, pats, 1) require.NotNil(t, pats[0].LastUsedAt) + require.Equal(t, now.AsTime(), pats[0].LastUsedAt.AsTime()) + + // An older asynchronous update must not make the last-used time regress. + err = ts.UpdatePATLastUsed(ctx, user.ID, "pat-update-test", timestamppb.New(now.AsTime().Add(-time.Hour))) + require.NoError(t, err) + pats, err = ts.GetUserPersonalAccessTokens(ctx, user.ID) + require.NoError(t, err) + require.Equal(t, now.AsTime(), pats[0].LastUsedAt.AsTime()) ts.Close() } diff --git a/store/user_setting.go b/store/user_setting.go index c5c208d9..17829426 100644 --- a/store/user_setting.go +++ b/store/user_setting.go @@ -5,6 +5,7 @@ import ( "github.com/pkg/errors" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" storepb "github.com/usememos/memos/proto/gen/store" @@ -237,6 +238,9 @@ func (s *Store) GetUserPersonalAccessTokens(ctx context.Context, userID int32) ( // AddUserPersonalAccessToken adds a new PAT for the user. func (s *Store) AddUserPersonalAccessToken(ctx context.Context, userID int32, token *storepb.PersonalAccessTokensUserSetting_PersonalAccessToken) error { + s.patMu.Lock() + defer s.patMu.Unlock() + tokens, err := s.GetUserPersonalAccessTokens(ctx, userID) if err != nil { return err @@ -258,6 +262,9 @@ func (s *Store) AddUserPersonalAccessToken(ctx context.Context, userID int32, to // RemoveUserPersonalAccessToken removes a PAT from the user. func (s *Store) RemoveUserPersonalAccessToken(ctx context.Context, userID int32, tokenID string) error { + s.patMu.Lock() + defer s.patMu.Unlock() + existingTokens, err := s.GetUserPersonalAccessTokens(ctx, userID) if err != nil { return err @@ -284,28 +291,45 @@ func (s *Store) RemoveUserPersonalAccessToken(ctx context.Context, userID int32, // UpdatePATLastUsed updates the last_used_at timestamp of a PAT. func (s *Store) UpdatePATLastUsed(ctx context.Context, userID int32, tokenID string, lastUsed *timestamppb.Timestamp) error { + s.patMu.Lock() + defer s.patMu.Unlock() + tokens, err := s.GetUserPersonalAccessTokens(ctx, userID) if err != nil { return err } - for _, token := range tokens { + for i, token := range tokens { if token.TokenId == tokenID { - token.LastUsedAt = lastUsed - break + // Concurrent requests can finish out of order. Never let an older usage + // timestamp overwrite a newer one. + if lastUsed != nil && token.LastUsedAt != nil && !token.LastUsedAt.AsTime().Before(lastUsed.AsTime()) { + return nil + } + + updatedToken, ok := proto.Clone(token).(*storepb.PersonalAccessTokensUserSetting_PersonalAccessToken) + if !ok { + return errors.Errorf("failed to clone personal access token") + } + updatedToken.LastUsedAt = lastUsed + updatedTokens := make([]*storepb.PersonalAccessTokensUserSetting_PersonalAccessToken, len(tokens)) + copy(updatedTokens, tokens) + updatedTokens[i] = updatedToken + + _, err = s.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: userID, + Key: storepb.UserSetting_PERSONAL_ACCESS_TOKENS, + Value: &storepb.UserSetting_PersonalAccessTokens{ + PersonalAccessTokens: &storepb.PersonalAccessTokensUserSetting{ + Tokens: updatedTokens, + }, + }, + }) + return err } } - _, err = s.UpsertUserSetting(ctx, &storepb.UserSetting{ - UserId: userID, - Key: storepb.UserSetting_PERSONAL_ACCESS_TOKENS, - Value: &storepb.UserSetting_PersonalAccessTokens{ - PersonalAccessTokens: &storepb.PersonalAccessTokensUserSetting{ - Tokens: tokens, - }, - }, - }) - return err + return nil } // GetUserWebhooks returns the webhooks of the user. diff --git a/web/src/components/CreateAccessTokenDialog.tsx b/web/src/components/CreateAccessTokenDialog.tsx index 3da48248..e1233818 100644 --- a/web/src/components/CreateAccessTokenDialog.tsx +++ b/web/src/components/CreateAccessTokenDialog.tsx @@ -42,7 +42,7 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) { value: 30, }, { - label: "90 Days", + label: t("setting.access-token.create-dialog.duration-90d"), value: 90, }, { diff --git a/web/src/components/Settings/AccessTokenSection.tsx b/web/src/components/Settings/AccessTokenSection.tsx index 56c1e2ac..3b0537dc 100644 --- a/web/src/components/Settings/AccessTokenSection.tsx +++ b/web/src/components/Settings/AccessTokenSection.tsx @@ -1,6 +1,6 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; import copy from "copy-to-clipboard"; -import { PlusIcon, TrashIcon } from "lucide-react"; +import { CopyIcon, ExternalLinkIcon, PlusIcon, TrashIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "react-hot-toast"; import ConfirmDialog from "@/components/ConfirmDialog"; @@ -13,8 +13,30 @@ import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/ import { useTranslate } from "@/utils/i18n"; import CreateAccessTokenDialog from "../CreateAccessTokenDialog"; import SettingGroup from "./SettingGroup"; +import SettingSection from "./SettingSection"; import SettingTable from "./SettingTable"; +const ApiUsageExample = () => { + const t = useTranslate(); + const example = `curl ${window.location.origin}/api/v1/memos \\\n -H "Authorization: Bearer memos_pat_..."`; + + const handleCopy = () => { + copy(example); + toast.success(t("message.copied")); + }; + + return ( +
+
+        {example}
+      
+ +
+ ); +}; + const listAccessTokens = async (parent: string) => { const { personalAccessTokens } = await userServiceClient.listPersonalAccessTokens({ parent }); return personalAccessTokens.sort( @@ -79,7 +101,7 @@ const AccessTokenSection = () => { }; return ( - { } > - {token.description}, - }, - { - key: "createdAt", - header: t("setting.access-token.create-dialog.created-at"), - render: (_, token: PersonalAccessToken) => (token.createdAt ? timestampDate(token.createdAt) : undefined)?.toLocaleString(), - }, - { - key: "expiresAt", - header: t("setting.access-token.create-dialog.expires-at"), - render: (_, token: PersonalAccessToken) => - (token.expiresAt ? timestampDate(token.expiresAt) : undefined)?.toLocaleString() ?? - t("setting.access-token.create-dialog.duration-never"), - }, - { - key: "actions", - header: "", - className: "text-right", - render: (_, token: PersonalAccessToken) => ( - - ), - }, - ]} - data={personalAccessTokens} - emptyMessage={t("setting.access-token.no-tokens-found")} - getRowKey={(token) => token.name} - /> +
+
+

{t("setting.access-token.about-title")}

+

{t("setting.access-token.about-description")}

+ + + {t("common.learn-more")} + + +
+
+

{t("setting.access-token.guidelines-title")}

+
    +
  • {t("setting.access-token.guideline-shown-once")}
  • +
  • {t("setting.access-token.guideline-one-per-app")}
  • +
  • {t("setting.access-token.guideline-expiration")}
  • +
  • {t("setting.access-token.guideline-review")}
  • +
+
+
+ + + {token.description}, + }, + { + key: "createdAt", + header: t("setting.access-token.create-dialog.created-at"), + render: (_, token: PersonalAccessToken) => (token.createdAt ? timestampDate(token.createdAt) : undefined)?.toLocaleString(), + }, + { + key: "expiresAt", + header: t("setting.access-token.create-dialog.expires-at"), + render: (_, token: PersonalAccessToken) => + (token.expiresAt ? timestampDate(token.expiresAt) : undefined)?.toLocaleString() ?? + t("setting.access-token.create-dialog.duration-never"), + }, + { + key: "lastUsedAt", + header: t("setting.access-token.last-used-at"), + render: (_, token: PersonalAccessToken) => + (token.lastUsedAt ? timestampDate(token.lastUsedAt) : undefined)?.toLocaleString() ?? t("setting.access-token.never-used"), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (_, token: PersonalAccessToken) => ( + + ), + }, + ]} + data={personalAccessTokens} + emptyMessage={t("setting.access-token.no-tokens-found")} + getRowKey={(token) => token.name} + /> + {/* Create Access Token Dialog */} { onConfirm={confirmDeleteAccessToken} confirmVariant="destructive" /> -
+ ); }; diff --git a/web/src/components/Settings/MyAccountSection.tsx b/web/src/components/Settings/MyAccountSection.tsx index 8e9a55e6..5f93f0fb 100644 --- a/web/src/components/Settings/MyAccountSection.tsx +++ b/web/src/components/Settings/MyAccountSection.tsx @@ -14,7 +14,6 @@ import { useTranslate } from "@/utils/i18n"; import ChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog"; import UpdateAccountDialog from "../UpdateAccountDialog"; import UserAvatar from "../UserAvatar"; -import AccessTokenSection from "./AccessTokenSection"; import LinkedIdentitySection from "./LinkedIdentitySection"; import SettingGroup from "./SettingGroup"; import SettingSection from "./SettingSection"; @@ -46,9 +45,9 @@ const MyAccountSection = () => { return ( -
+
-
+
{user?.displayName} @{user?.username} @@ -70,8 +69,6 @@ const MyAccountSection = () => { - -
diff --git a/web/src/components/Settings/SectionChip.tsx b/web/src/components/Settings/SectionChip.tsx new file mode 100644 index 00000000..a19440ef --- /dev/null +++ b/web/src/components/Settings/SectionChip.tsx @@ -0,0 +1,37 @@ +import React, { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +interface SectionChipProps { + text: string; + href: string; + isSelected: boolean; +} + +/** Compact pill counterpart of SectionMenuItem, for the horizontal strip on narrow screens. */ +const SectionChip: React.FC = ({ text, href, isSelected }) => { + const chipRef = useRef(null); + + useEffect(() => { + if (isSelected) { + chipRef.current?.scrollIntoView({ inline: "center", block: "nearest" }); + } + }, [isSelected]); + + return ( + + {text} + + ); +}; + +export default SectionChip; diff --git a/web/src/components/Settings/SectionMenuItem.tsx b/web/src/components/Settings/SectionMenuItem.tsx index cf949261..8d019692 100644 --- a/web/src/components/Settings/SectionMenuItem.tsx +++ b/web/src/components/Settings/SectionMenuItem.tsx @@ -1,24 +1,27 @@ import { LucideIcon } from "lucide-react"; import React from "react"; +import { cn } from "@/lib/utils"; interface SectionMenuItemProps { text: string; icon: LucideIcon; + href: string; isSelected: boolean; - onClick: () => void; } -const SectionMenuItem: React.FC = ({ text, icon: IconComponent, isSelected, onClick }) => { +const SectionMenuItem: React.FC = ({ text, icon: IconComponent, href, isSelected }) => { return ( -
- + {text} -
+ ); }; diff --git a/web/src/components/Settings/settingSections.ts b/web/src/components/Settings/settingSections.ts index d3855e1f..917c6824 100644 --- a/web/src/components/Settings/settingSections.ts +++ b/web/src/components/Settings/settingSections.ts @@ -4,6 +4,7 @@ import { DatabaseIcon, HeartHandshakeIcon, KeyIcon, + KeyRoundIcon, LibraryIcon, type LucideIcon, MailIcon, @@ -14,6 +15,7 @@ import { WebhookIcon, } from "lucide-react"; import { type ComponentType } from "react"; +import AccessTokenSection from "@/components/Settings/AccessTokenSection"; import AISection from "@/components/Settings/AISection"; import InstanceSection from "@/components/Settings/InstanceSection"; import MemberSection from "@/components/Settings/MemberSection"; @@ -30,6 +32,7 @@ import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb"; export type SettingSectionKey = | "my-account" + | "access-token" | "preference" | "webhook" | "member" @@ -61,6 +64,13 @@ export const SETTINGS_SECTIONS: SettingSectionDefinition[] = [ icon: UserIcon, component: MyAccountSection, }, + { + key: "access-token", + scope: "basic", + labelKey: "setting.access-token.label", + icon: KeyRoundIcon, + component: AccessTokenSection, + }, { key: "preference", scope: "basic", diff --git a/web/src/locales/bg.json b/web/src/locales/bg.json index a25b735e..72a3d209 100644 --- a/web/src/locales/bg.json +++ b/web/src/locales/bg.json @@ -733,7 +733,6 @@ "server-name": "Име на сървъра", "title": "генерал" }, - "select-section": "Изберете раздел", "version": "Версия", "webhook": { "create-dialog": { diff --git a/web/src/locales/ca.json b/web/src/locales/ca.json index bd443afc..b4a9604a 100644 --- a/web/src/locales/ca.json +++ b/web/src/locales/ca.json @@ -736,7 +736,6 @@ "use-tls": "Utilitzeu STARTTLS", "use-tls-description": "Actualitzeu la connexió SMTP amb STARTTLS. Mantingueu-ho activat per a Gmail amb el port 587." }, - "select-section": "Seleccioneu la secció", "resource-stats": { "label": "Recursos", "title": "Estadístiques de recursos", diff --git a/web/src/locales/cs.json b/web/src/locales/cs.json index 8aa0d643..fe765de7 100644 --- a/web/src/locales/cs.json +++ b/web/src/locales/cs.json @@ -736,7 +736,6 @@ "use-tls": "Použijte STARTTLS", "use-tls-description": "Upgradujte připojení SMTP pomocí STARTTLS. Nechte toto zapnuté pro Gmail s portem 587." }, - "select-section": "Vyberte sekci", "resource-stats": { "label": "Zdroje", "title": "Statistika zdrojů", diff --git a/web/src/locales/da.json b/web/src/locales/da.json index 3b00b95f..7a3ac07c 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -733,7 +733,6 @@ "server-name": "Servernavn", "title": "Generel" }, - "select-section": "Vælg afsnit", "version": "Version", "webhook": { "create-dialog": { diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 3e7cf72d..60ab97d8 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -736,7 +736,6 @@ "use-tls": "Verwenden Sie STARTTLS", "use-tls-description": "Rüsten Sie die SMTP-Verbindung mit STARTTLS auf. Behalten Sie dies für Gmail mit Port 587 bei." }, - "select-section": "Abschnitt auswählen", "resource-stats": { "label": "Ressourcen", "title": "Ressourcenstatistik", diff --git a/web/src/locales/el.json b/web/src/locales/el.json index cfb0b408..450be341 100644 --- a/web/src/locales/el.json +++ b/web/src/locales/el.json @@ -733,7 +733,6 @@ "server-name": "Όνομα διακομιστή", "title": "Γενικός" }, - "select-section": "Επιλέξτε ενότητα", "version": "Εκδοχή", "webhook": { "create-dialog": { diff --git a/web/src/locales/en-GB.json b/web/src/locales/en-GB.json index 5b7aa115..ca2ebfb6 100644 --- a/web/src/locales/en-GB.json +++ b/web/src/locales/en-GB.json @@ -736,7 +736,6 @@ "use-tls": "Use STARTTLS", "use-tls-description": "Upgrade the SMTP connection with STARTTLS. Keep this on for Gmail with port 587." }, - "select-section": "Select section", "resource-stats": { "label": "Resources", "title": "Resource Statistics", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 5a10ce64..619f9771 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -440,13 +440,25 @@ "created-at": "Created At", "description": "Description", "duration-1m": "1 Month", + "duration-90d": "90 Days", "duration-8h": "8 Hours", "duration-never": "Never", "expiration": "Expiration", "expires-at": "Expires At", "some-description": "Some description..." }, - "description": "A list of all access tokens for your account.", + "description": "Create and revoke the secret keys that let other apps use the Memos API as you.", + "about-title": "What is a personal access token?", + "about-description": "A personal access token (PAT) is a secret key that authenticates API requests as your account. Any app or script holding one — an MCP server, a CLI, a mobile client — can do everything you can do in Memos, until the token expires or you delete it. Send it as a Bearer credential in the Authorization header:", + "guidelines-title": "Keep your tokens safe", + "guideline-shown-once": "A token is shown only once, right after you create it (it is copied to your clipboard). Store it somewhere safe, like a password manager.", + "guideline-one-per-app": "Create a separate token for each app or script, so you can revoke one without breaking the others.", + "guideline-expiration": "Prefer tokens that expire. Long-lived tokens are a bigger risk if they leak.", + "guideline-review": "Check the Last used column from time to time, and delete tokens you no longer recognize or need.", + "your-tokens": "Your tokens", + "label": "Access Tokens", + "last-used-at": "Last Used", + "never-used": "Never used", "title": "Access Tokens", "token": "Token", "no-tokens-found": "No access tokens found" @@ -785,7 +797,6 @@ "server-name": "Server Name", "title": "General" }, - "select-section": "Select section", "version": "Version", "webhook": { "create-dialog": { diff --git a/web/src/locales/es.json b/web/src/locales/es.json index afb66f41..ac7c4295 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -736,7 +736,6 @@ "use-tls": "Utilice STARTTLS", "use-tls-description": "Actualice la conexión SMTP con STARTTLS. Mantenga esto activado para Gmail con el puerto 587." }, - "select-section": "Seleccionar sección", "resource-stats": { "label": "Recursos", "title": "Estadísticas de recursos", diff --git a/web/src/locales/et.json b/web/src/locales/et.json index 4f67a3e5..79e4134e 100644 --- a/web/src/locales/et.json +++ b/web/src/locales/et.json @@ -733,7 +733,6 @@ "server-name": "Serveri nimi", "title": "Kindral" }, - "select-section": "Valige jaotis", "version": "Versioon", "webhook": { "create-dialog": { diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index cf8a0559..d75f5c72 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -733,7 +733,6 @@ "server-name": "Palvelimen nimi", "title": "Kenraali" }, - "select-section": "Valitse osio", "version": "Versio", "webhook": { "create-dialog": { diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 1f3ff3c1..8385c9cd 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -736,7 +736,6 @@ "use-tls": "Utiliser STARTTLS", "use-tls-description": "Mettez à niveau la connexion SMTP avec STARTTLS. Gardez ceci activé pour Gmail avec le port 587." }, - "select-section": "Sélectionner une rubrique", "resource-stats": { "label": "Ressources", "title": "Statistiques des ressources", diff --git a/web/src/locales/gl.json b/web/src/locales/gl.json index 81d0d192..5f1f4b39 100644 --- a/web/src/locales/gl.json +++ b/web/src/locales/gl.json @@ -736,7 +736,6 @@ "use-tls": "Use STARTTLS", "use-tls-description": "Actualiza a conexión SMTP con STARTTLS. Mantén isto activado para Gmail co porto 587." }, - "select-section": "Seleccione sección", "resource-stats": { "label": "Recursos", "title": "Estatística de recursos", diff --git a/web/src/locales/hr.json b/web/src/locales/hr.json index 540e3355..15aa34ce 100644 --- a/web/src/locales/hr.json +++ b/web/src/locales/hr.json @@ -736,7 +736,6 @@ "use-tls": "Koristite STARTTLS", "use-tls-description": "Nadogradite vezu SMTP s STARTTLS. Neka ovo bude uključeno za Gmail s priključkom 587." }, - "select-section": "Odaberite odjeljak", "resource-stats": { "label": "Resursi", "title": "Statistika resursa", diff --git a/web/src/locales/hu.json b/web/src/locales/hu.json index 5af02e5e..3d809b67 100644 --- a/web/src/locales/hu.json +++ b/web/src/locales/hu.json @@ -736,7 +736,6 @@ "use-tls": "Használja az STARTTLS-et", "use-tls-description": "Frissítse az SMTP kapcsolatot STARTTLS-szel. Tartsa bekapcsolva az Gmail esetében az 587 porttal." }, - "select-section": "Válassza ki a szakaszt", "resource-stats": { "label": "Erőforrás", "title": "Erőforrás-statisztika", diff --git a/web/src/locales/it.json b/web/src/locales/it.json index 26d65e08..d3bac3b1 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -736,7 +736,6 @@ "use-tls": "Usa STARTTLS", "use-tls-description": "Aggiorna la connessione SMTP con STARTTLS. Mantienilo attivo per Gmail con porta 587." }, - "select-section": "Seleziona sezione", "resource-stats": { "label": "Risorse", "title": "Statistiche delle risorse", diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index a243c62d..d74809f1 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -762,7 +762,6 @@ "use-tls": "STARTTLSを使用する", "use-tls-description": "SMTP 接続を STARTTLS にアップグレードします。 ポート 587 の Gmail ではこれをオンのままにします。" }, - "select-section": "セクションを選択", "resource-stats": { "label": "リソース", "title": "リソース統計", diff --git a/web/src/locales/ka-GE.json b/web/src/locales/ka-GE.json index 0e26588b..7a674393 100644 --- a/web/src/locales/ka-GE.json +++ b/web/src/locales/ka-GE.json @@ -736,7 +736,6 @@ "use-tls": "გამოიყენეთ STARTTLS", "use-tls-description": "განაახლეთ SMTP კავშირი STARTTLS-ით. შეინახეთ ეს Gmail-ისთვის 587 პორტით." }, - "select-section": "აირჩიეთ განყოფილება", "resource-stats": { "label": "რესურსები", "title": "რესურსების სტატისტიკა", diff --git a/web/src/locales/lt.json b/web/src/locales/lt.json index 9004e528..ef00617f 100644 --- a/web/src/locales/lt.json +++ b/web/src/locales/lt.json @@ -733,7 +733,6 @@ "server-name": "Serverio pavadinimas", "title": "Generolas" }, - "select-section": "Pasirinkite skyrių", "version": "Versija", "webhook": { "create-dialog": { diff --git a/web/src/locales/lv.json b/web/src/locales/lv.json index 743024fd..a34bce58 100644 --- a/web/src/locales/lv.json +++ b/web/src/locales/lv.json @@ -733,7 +733,6 @@ "server-name": "Servera nosaukums", "title": "Ģenerālis" }, - "select-section": "Izvēlieties sadaļu", "version": "Versija", "webhook": { "create-dialog": { diff --git a/web/src/locales/nb.json b/web/src/locales/nb.json index b1806254..d816bf12 100644 --- a/web/src/locales/nb.json +++ b/web/src/locales/nb.json @@ -736,7 +736,6 @@ "use-tls": "Bruk STARTTLS", "use-tls-description": "Oppgrader SMTP-tilkoblingen med STARTTLS. Hold dette på for Gmail med port 587." }, - "select-section": "Velg seksjon", "resource-stats": { "label": "Ressurser", "title": "Ressursstatistikk", diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index 8a4f575f..c43a7530 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -736,7 +736,6 @@ "use-tls": "Gebruik STARTTLS", "use-tls-description": "Upgrade de SMTP-verbinding met STARTTLS. Houd dit ingeschakeld voor Gmail met poort 587." }, - "select-section": "Selecteer sectie", "resource-stats": { "label": "Bronnen", "title": "Statistieken van hulpbronnen", diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index a55b55a0..5ef57479 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -768,7 +768,6 @@ "use-tls": "Użyj STARTTLS", "use-tls-description": "Zaktualizuj połączenie SMTP za pomocą STARTTLS. Pozostaw tę opcję włączoną dla Gmail z portem 587." }, - "select-section": "Wybierz sekcję", "resource-stats": { "label": "Zasoby", "title": "Statystyki zasobów", diff --git a/web/src/locales/pt-PT.json b/web/src/locales/pt-PT.json index 908fc9f2..88c3a2e6 100644 --- a/web/src/locales/pt-PT.json +++ b/web/src/locales/pt-PT.json @@ -736,7 +736,6 @@ "use-tls": "Usar STARTTLS", "use-tls-description": "Atualize a conexão SMTP com STARTTLS. Mantenha isso ativado para Gmail com porta 587." }, - "select-section": "Selecione a seção", "resource-stats": { "label": "Recursos", "title": "Estatísticas de recursos", diff --git a/web/src/locales/ro.json b/web/src/locales/ro.json index 3bb26434..5f2869b3 100644 --- a/web/src/locales/ro.json +++ b/web/src/locales/ro.json @@ -733,7 +733,6 @@ "server-name": "Nume server", "title": "General" }, - "select-section": "Selectați secțiunea", "version": "Versiune", "webhook": { "create-dialog": { diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index a9875980..496cd278 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -736,7 +736,6 @@ "use-tls": "Используйте STARTTLS", "use-tls-description": "Обновите соединение SMTP с помощью STARTTLS. Оставьте это значение для Gmail с портом 587." }, - "select-section": "Выберите раздел", "resource-stats": { "label": "Ресурсы", "title": "Статистика ресурсов", diff --git a/web/src/locales/sk.json b/web/src/locales/sk.json index 36163314..b0a5f8ad 100644 --- a/web/src/locales/sk.json +++ b/web/src/locales/sk.json @@ -733,7 +733,6 @@ "server-name": "Názov servera", "title": "generál" }, - "select-section": "Vyberte sekciu", "version": "Verzia", "webhook": { "create-dialog": { diff --git a/web/src/locales/sl.json b/web/src/locales/sl.json index 0c8c4e0e..b94a75e7 100644 --- a/web/src/locales/sl.json +++ b/web/src/locales/sl.json @@ -736,7 +736,6 @@ "use-tls": "Uporabite STARTTLS", "use-tls-description": "Nadgradite povezavo SMTP z STARTTLS. Naj bo to vključeno za Gmail z vrati 587." }, - "select-section": "Izberite razdelek", "resource-stats": { "label": "Viri", "title": "Statistika virov", diff --git a/web/src/locales/sr.json b/web/src/locales/sr.json index 63ad8c4c..e01fff4f 100644 --- a/web/src/locales/sr.json +++ b/web/src/locales/sr.json @@ -733,7 +733,6 @@ "server-name": "Име сервера", "title": "генерал" }, - "select-section": "Изаберите одељак", "version": "Версион", "webhook": { "create-dialog": { diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index eb16253b..85a71f99 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -736,7 +736,6 @@ "use-tls": "Använd STARTTLS", "use-tls-description": "Uppgradera SMTP-anslutningen med STARTTLS. Behåll detta för Gmail med port 587." }, - "select-section": "Välj avsnitt", "resource-stats": { "label": "Resurser", "title": "Resursstatistik", diff --git a/web/src/locales/tr.json b/web/src/locales/tr.json index 6111103c..0cb3b27c 100644 --- a/web/src/locales/tr.json +++ b/web/src/locales/tr.json @@ -736,7 +736,6 @@ "use-tls": "STARTTLS'i kullanın", "use-tls-description": "SMTP bağlantısını STARTTLS ile yükseltin. 587 bağlantı noktasına sahip Gmail için bunu açık tutun." }, - "select-section": "Bölüm seç", "resource-stats": { "label": "Kaynaklar", "title": "Kaynak İstatistikleri", diff --git a/web/src/locales/uk.json b/web/src/locales/uk.json index ab6d92ed..053544a5 100644 --- a/web/src/locales/uk.json +++ b/web/src/locales/uk.json @@ -736,7 +736,6 @@ "use-tls": "Використовуйте STARTTLS", "use-tls-description": "Оновіть з’єднання SMTP за допомогою STARTTLS. Залиште це для Gmail з портом 587." }, - "select-section": "Виберіть розділ", "resource-stats": { "label": "Ресурси", "title": "Статистика ресурсів", diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 18746c28..a8c97d9e 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -636,7 +636,6 @@ "title": "一般设置", "label": "系统" }, - "select-section": "选择设置项", "version": "版本", "access-token": { "access-token-copied-to-clipboard": "访问令牌已复制到剪贴板", diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index 34f0b29e..2c5297d0 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -727,7 +727,6 @@ "server-name": "伺服器名稱", "title": "系統設定" }, - "select-section": "選擇區段", "version": "版本", "webhook": { "create-dialog": { diff --git a/web/src/pages/Setting.tsx b/web/src/pages/Setting.tsx index 4e8d3328..f5d2e879 100644 --- a/web/src/pages/Setting.tsx +++ b/web/src/pages/Setting.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import MobileHeader from "@/components/MobileHeader"; +import SectionChip from "@/components/Settings/SectionChip"; import SectionMenuItem from "@/components/Settings/SectionMenuItem"; import { DEFAULT_SETTING_SECTION, @@ -9,16 +10,18 @@ import { type SettingSectionDefinition, type SettingSectionKey, } from "@/components/Settings/settingSections"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useInstance } from "@/contexts/InstanceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import useMediaQuery from "@/hooks/useMediaQuery"; import { User_Role } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; +const NAV_GROUP_LABEL_CLASSES = "mb-1 px-2 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55 select-none"; + const Setting = () => { const t = useTranslate(); const sm = useMediaQuery("sm"); + const md = useMediaQuery("md"); const location = useLocation(); const user = useCurrentUser(); const { fetchSettings } = useInstance(); @@ -36,17 +39,22 @@ const Setting = () => { const visibleSectionKeys = useMemo(() => new Set(sectionGroups.all.map((section) => section.key)), [sectionGroups.all]); - const sectionOptions = useMemo( - () => sectionGroups.all.map((section) => ({ value: section.key, label: t(section.labelKey) })), - [sectionGroups.all, t], - ); - useEffect(() => { const hash = location.hash.slice(1); const nextSection = isSettingSectionKey(hash) && visibleSectionKeys.has(hash) ? hash : DEFAULT_SETTING_SECTION; setSelectedSection(nextSection); }, [location.hash, visibleSectionKeys]); + // Jump back to the top when switching sections; skip the initial hash sync so + // scroll restoration on back-navigation still wins. + const prevSectionRef = useRef(null); + useEffect(() => { + if (prevSectionRef.current && prevSectionRef.current !== selectedSection) { + window.scrollTo({ top: 0 }); + } + prevSectionRef.current = selectedSection; + }, [selectedSection]); + useEffect(() => { if (!isHost) { return; @@ -55,10 +63,6 @@ const Setting = () => { void fetchSettings([...preloadSettingKeys]); }, [fetchSettings, isHost, sectionGroups.admin]); - const handleSectionSelectorItemClick = (section: SettingSectionKey) => { - window.location.hash = section; - }; - const selectedSectionDefinition = sectionGroups.all.find((section) => section.key === selectedSection) ?? SETTINGS_SECTIONS.find((section) => section.key === DEFAULT_SETTING_SECTION) ?? @@ -71,52 +75,53 @@ const Setting = () => { key={section.key} text={t(section.labelKey)} icon={section.icon} + href={`#${section.key}`} isSelected={selectedSection === section.key} - onClick={() => handleSectionSelectorItemClick(section.key)} /> )); + const renderSectionChips = (sections: SettingSectionDefinition[]) => + sections.map((section) => ( + + )); + return ( -
+
{!sm && } -
-
- {sm && ( -
- {t("common.basic")} -
{renderSectionMenuItems(sectionGroups.basic)}
- {isHost && ( - <> - {t("common.admin")} -
{renderSectionMenuItems(sectionGroups.admin)}
- - )} -
- )} -
- {!sm && ( -
- +
+ {md && ( +
-
+ {isHost && ( +
+

{t("common.admin")}

+ {renderSectionMenuItems(sectionGroups.admin)} +
+ )} + + + )} +
+ {!md && ( +
+

{t("common.settings")}

+ +
+ )} + +
);