chore: enrich access tokens setting page
Settings drops the all-in-one bordered card for a de-carded layout in the property-rail design language: a sticky table-of-contents rail at md+ (Settings wordmark, uppercase group labels, quiet anchor rows) and a horizontally swipeable chip strip below md, replacing the mobile section dropdown. Nav items are real anchors with aria-current, and switching sections scrolls back to the top. Access Tokens becomes a first-class section with an explainer panel: what a PAT is and a copyable curl example (real instance origin, memos_pat_ prefix) beside token-safety guidelines in a two-column band, with a Learn more docs link and the tokens table beneath. Successful PAT authentication now records the token's lastUsedAt asynchronously inside resolveBearer, with a clone-before-mutate cache guard and monotonic writes in the store, surfaced in a Last used column. Also localizes the create dialog's 90 Days label, lets the My Account row wrap instead of clipping on narrow screens, and drops the dead select-section key from all locales.
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -16,6 +16,7 @@ type Store struct {
|
||||
|
||||
userCreateMu sync.Mutex
|
||||
authConfigMu sync.Mutex
|
||||
patMu sync.Mutex
|
||||
|
||||
deploymentConfigMu sync.RWMutex
|
||||
deploymentConfig *deploymentConfiguration
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+37
-13
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative w-full min-w-0 rounded-lg border border-border/60 bg-background">
|
||||
<pre className="overflow-x-auto p-3 pr-12 font-mono text-xs leading-5 text-foreground/85">
|
||||
<code>{example}</code>
|
||||
</pre>
|
||||
<Button variant="ghost" size="icon" className="absolute top-1.5 right-1.5" aria-label={t("common.copy")} onClick={handleCopy}>
|
||||
<CopyIcon className="w-3.5 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const listAccessTokens = async (parent: string) => {
|
||||
const { personalAccessTokens } = await userServiceClient.listPersonalAccessTokens({ parent });
|
||||
return personalAccessTokens.sort(
|
||||
@@ -79,7 +101,7 @@ const AccessTokenSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingGroup
|
||||
<SettingSection
|
||||
title={t("setting.access-token.title")}
|
||||
description={t("setting.access-token.description")}
|
||||
actions={
|
||||
@@ -89,40 +111,74 @@ const AccessTokenSection = () => {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SettingTable
|
||||
columns={[
|
||||
{
|
||||
key: "description",
|
||||
header: t("common.description"),
|
||||
render: (_, token: PersonalAccessToken) => <span className="text-foreground">{token.description}</span>,
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteAccessToken(token)}>
|
||||
<TrashIcon className="text-destructive w-4 h-auto" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={personalAccessTokens}
|
||||
emptyMessage={t("setting.access-token.no-tokens-found")}
|
||||
getRowKey={(token) => token.name}
|
||||
/>
|
||||
<div className="grid w-full min-w-0 rounded-xl border border-border/60 bg-muted/20 lg:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-2.5 p-4 sm:p-5">
|
||||
<h4 className="text-sm font-medium text-foreground">{t("setting.access-token.about-title")}</h4>
|
||||
<p className="text-[13px] leading-6 text-muted-foreground">{t("setting.access-token.about-description")}</p>
|
||||
<ApiUsageExample />
|
||||
<a
|
||||
className="inline-flex w-fit items-center gap-1 text-[13px] leading-5 text-muted-foreground underline-offset-4 hover:text-primary hover:underline"
|
||||
href="https://usememos.com/docs/security/access-tokens"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("common.learn-more")}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-2.5 border-t border-border/60 p-4 sm:p-5 lg:border-t-0 lg:border-l">
|
||||
<h4 className="text-sm font-medium text-foreground">{t("setting.access-token.guidelines-title")}</h4>
|
||||
<ul className="flex list-disc flex-col gap-2 pl-4 text-[13px] leading-5 text-muted-foreground marker:text-muted-foreground/40">
|
||||
<li>{t("setting.access-token.guideline-shown-once")}</li>
|
||||
<li>{t("setting.access-token.guideline-one-per-app")}</li>
|
||||
<li>{t("setting.access-token.guideline-expiration")}</li>
|
||||
<li>{t("setting.access-token.guideline-review")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingGroup title={t("setting.access-token.your-tokens")}>
|
||||
<SettingTable
|
||||
columns={[
|
||||
{
|
||||
key: "description",
|
||||
header: t("common.description"),
|
||||
render: (_, token: PersonalAccessToken) => <span className="text-foreground">{token.description}</span>,
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteAccessToken(token)}>
|
||||
<TrashIcon className="text-destructive w-4 h-auto" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={personalAccessTokens}
|
||||
emptyMessage={t("setting.access-token.no-tokens-found")}
|
||||
getRowKey={(token) => token.name}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
{/* Create Access Token Dialog */}
|
||||
<CreateAccessTokenDialog
|
||||
@@ -140,7 +196,7 @@ const AccessTokenSection = () => {
|
||||
onConfirm={confirmDeleteAccessToken}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<SettingSection title={t("setting.my-account.label")}>
|
||||
<SettingGroup title={t("setting.account.title")}>
|
||||
<div className="w-full flex flex-row justify-start items-center gap-3">
|
||||
<div className="w-full flex flex-row flex-wrap justify-start items-center gap-3">
|
||||
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} />
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center items-start gap-1">
|
||||
<div className="flex-1 min-w-40 flex flex-col justify-center items-start gap-1">
|
||||
<div className="w-full">
|
||||
<span className="text-lg font-semibold">{user?.displayName}</span>
|
||||
<span className="ml-2 text-sm text-muted-foreground">@{user?.username}</span>
|
||||
@@ -70,8 +69,6 @@ const MyAccountSection = () => {
|
||||
|
||||
<LinkedIdentitySection />
|
||||
|
||||
<AccessTokenSection />
|
||||
|
||||
<SettingGroup showSeparator title={t("setting.account.danger-area")} description={t("setting.account.danger-area-description")}>
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -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<SectionChipProps> = ({ text, href, isSelected }) => {
|
||||
const chipRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
chipRef.current?.scrollIntoView({ inline: "center", block: "nearest" });
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={chipRef}
|
||||
href={href}
|
||||
aria-current={isSelected ? "page" : undefined}
|
||||
className={cn(
|
||||
"shrink-0 whitespace-nowrap rounded-full border px-3 py-1 text-[13px] leading-5 transition-colors",
|
||||
isSelected
|
||||
? "border-transparent bg-accent font-medium text-foreground"
|
||||
: "border-border/70 text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionChip;
|
||||
@@ -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<SectionMenuItemProps> = ({ text, icon: IconComponent, isSelected, onClick }) => {
|
||||
const SectionMenuItem: React.FC<SectionMenuItemProps> = ({ text, icon: IconComponent, href, isSelected }) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`w-auto max-w-full px-3 leading-8 flex flex-row justify-start items-center cursor-pointer rounded-lg select-none hover:opacity-80 ${
|
||||
isSelected ? "bg-accent shadow" : ""
|
||||
}`}
|
||||
<a
|
||||
href={href}
|
||||
aria-current={isSelected ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 text-[13px] leading-5 transition-colors",
|
||||
isSelected ? "bg-accent font-medium text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<IconComponent className="w-4 h-auto mr-2 opacity-80 shrink-0" />
|
||||
<IconComponent className={cn("size-3.5 shrink-0", isSelected ? "opacity-80" : "opacity-60")} />
|
||||
<span className="truncate">{text}</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Име на сървъра",
|
||||
"title": "генерал"
|
||||
},
|
||||
"select-section": "Изберете раздел",
|
||||
"version": "Версия",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ů",
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Servernavn",
|
||||
"title": "Generel"
|
||||
},
|
||||
"select-section": "Vælg afsnit",
|
||||
"version": "Version",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Όνομα διακομιστή",
|
||||
"title": "Γενικός"
|
||||
},
|
||||
"select-section": "Επιλέξτε ενότητα",
|
||||
"version": "Εκδοχή",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+13
-2
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Serveri nimi",
|
||||
"title": "Kindral"
|
||||
},
|
||||
"select-section": "Valige jaotis",
|
||||
"version": "Versioon",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Palvelimen nimi",
|
||||
"title": "Kenraali"
|
||||
},
|
||||
"select-section": "Valitse osio",
|
||||
"version": "Versio",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -762,7 +762,6 @@
|
||||
"use-tls": "STARTTLSを使用する",
|
||||
"use-tls-description": "SMTP 接続を STARTTLS にアップグレードします。 ポート 587 の Gmail ではこれをオンのままにします。"
|
||||
},
|
||||
"select-section": "セクションを選択",
|
||||
"resource-stats": {
|
||||
"label": "リソース",
|
||||
"title": "リソース統計",
|
||||
|
||||
@@ -736,7 +736,6 @@
|
||||
"use-tls": "გამოიყენეთ STARTTLS",
|
||||
"use-tls-description": "განაახლეთ SMTP კავშირი STARTTLS-ით. შეინახეთ ეს Gmail-ისთვის 587 პორტით."
|
||||
},
|
||||
"select-section": "აირჩიეთ განყოფილება",
|
||||
"resource-stats": {
|
||||
"label": "რესურსები",
|
||||
"title": "რესურსების სტატისტიკა",
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Serverio pavadinimas",
|
||||
"title": "Generolas"
|
||||
},
|
||||
"select-section": "Pasirinkite skyrių",
|
||||
"version": "Versija",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Servera nosaukums",
|
||||
"title": "Ģenerālis"
|
||||
},
|
||||
"select-section": "Izvēlieties sadaļu",
|
||||
"version": "Versija",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Nume server",
|
||||
"title": "General"
|
||||
},
|
||||
"select-section": "Selectați secțiunea",
|
||||
"version": "Versiune",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -736,7 +736,6 @@
|
||||
"use-tls": "Используйте STARTTLS",
|
||||
"use-tls-description": "Обновите соединение SMTP с помощью STARTTLS. Оставьте это значение для Gmail с портом 587."
|
||||
},
|
||||
"select-section": "Выберите раздел",
|
||||
"resource-stats": {
|
||||
"label": "Ресурсы",
|
||||
"title": "Статистика ресурсов",
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Názov servera",
|
||||
"title": "generál"
|
||||
},
|
||||
"select-section": "Vyberte sekciu",
|
||||
"version": "Verzia",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -733,7 +733,6 @@
|
||||
"server-name": "Име сервера",
|
||||
"title": "генерал"
|
||||
},
|
||||
"select-section": "Изаберите одељак",
|
||||
"version": "Версион",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -736,7 +736,6 @@
|
||||
"use-tls": "Використовуйте STARTTLS",
|
||||
"use-tls-description": "Оновіть з’єднання SMTP за допомогою STARTTLS. Залиште це для Gmail з портом 587."
|
||||
},
|
||||
"select-section": "Виберіть розділ",
|
||||
"resource-stats": {
|
||||
"label": "Ресурси",
|
||||
"title": "Статистика ресурсів",
|
||||
|
||||
@@ -636,7 +636,6 @@
|
||||
"title": "一般设置",
|
||||
"label": "系统"
|
||||
},
|
||||
"select-section": "选择设置项",
|
||||
"version": "版本",
|
||||
"access-token": {
|
||||
"access-token-copied-to-clipboard": "访问令牌已复制到剪贴板",
|
||||
|
||||
@@ -727,7 +727,6 @@
|
||||
"server-name": "伺服器名稱",
|
||||
"title": "系統設定"
|
||||
},
|
||||
"select-section": "選擇區段",
|
||||
"version": "版本",
|
||||
"webhook": {
|
||||
"create-dialog": {
|
||||
|
||||
+55
-50
@@ -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<SettingSectionKey | null>(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) => (
|
||||
<SectionChip key={section.key} text={t(section.labelKey)} href={`#${section.key}`} isSelected={selectedSection === section.key} />
|
||||
));
|
||||
|
||||
return (
|
||||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
|
||||
<section className="w-full min-h-full">
|
||||
{!sm && <MobileHeader />}
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="w-full border border-border flex flex-row justify-start items-start px-4 py-3 rounded-xl bg-background text-muted-foreground">
|
||||
{sm && (
|
||||
<div className="flex flex-col justify-start items-start w-40 h-auto shrink-0 py-2">
|
||||
<span className="text-sm mt-0.5 pl-3 font-mono select-none text-muted-foreground">{t("common.basic")}</span>
|
||||
<div className="w-full flex flex-col justify-start items-start mt-1">{renderSectionMenuItems(sectionGroups.basic)}</div>
|
||||
{isHost && (
|
||||
<>
|
||||
<span className="text-sm mt-4 pl-3 font-mono select-none text-muted-foreground">{t("common.admin")}</span>
|
||||
<div className="w-full flex flex-col justify-start items-start mt-1">{renderSectionMenuItems(sectionGroups.admin)}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full grow sm:pl-4 overflow-x-auto">
|
||||
{!sm && (
|
||||
<div className="w-auto inline-block my-2">
|
||||
<Select
|
||||
value={selectedSection}
|
||||
items={sectionOptions}
|
||||
onValueChange={(value) => handleSectionSelectorItemClick(value as SettingSectionKey)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t("setting.select-section")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sectionOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-row items-start gap-8 px-4 pt-2 pb-12 sm:px-6 sm:pt-4 md:pt-8 lg:gap-10">
|
||||
{md && (
|
||||
<aside className="sticky top-8 flex w-48 shrink-0 flex-col gap-4">
|
||||
<h1 className="px-2 text-base font-semibold tracking-tight text-foreground">{t("common.settings")}</h1>
|
||||
<nav className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{isHost && <p className={NAV_GROUP_LABEL_CLASSES}>{t("common.basic")}</p>}
|
||||
{renderSectionMenuItems(sectionGroups.basic)}
|
||||
</div>
|
||||
)}
|
||||
<ActiveSection />
|
||||
</div>
|
||||
</div>
|
||||
{isHost && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/60 pt-3">
|
||||
<p className={NAV_GROUP_LABEL_CLASSES}>{t("common.admin")}</p>
|
||||
{renderSectionMenuItems(sectionGroups.admin)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
)}
|
||||
<main className="min-w-0 flex-1">
|
||||
{!md && (
|
||||
<header className="mb-5 flex flex-col gap-3">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-foreground">{t("common.settings")}</h1>
|
||||
<nav
|
||||
className="-mx-4 flex items-center gap-1.5 overflow-x-auto px-4 pb-1 sm:-mx-6 sm:px-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
aria-label={t("common.settings")}
|
||||
>
|
||||
{renderSectionChips(sectionGroups.basic)}
|
||||
{isHost && <span className="mx-1 h-4 w-px shrink-0 bg-border" aria-hidden="true" />}
|
||||
{isHost && renderSectionChips(sectionGroups.admin)}
|
||||
</nav>
|
||||
</header>
|
||||
)}
|
||||
<ActiveSection />
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user