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 (
+
+ );
+};
+
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.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")}
+
+
+ )}
+
+
);