diff --git a/ROADMAP.md b/ROADMAP.md index e038b3f8..db9ef1ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,8 +190,10 @@ Order = user-visible value first; each ships with backend + UI + tests. 5. **System extensions** — activity/audit log, site announcements, node selection, report-abuse queue (fixes #3480, #3479 IP whitelist). See §1.3a. - [x] PR #144 — task `creator_ip` capture with CIDR-capable admin filter (#115 OSS half), group remote-download quotas per count + per volume (#16), yt-dlp downloader provider (#88), progressive image preview (#113), v3 migrator `DatabaseURL` passthrough (#42) - [x] `activity_event` entity (immutable, tx-aware, actor+IP+CID) + per-file Activity dialog + admin `/admin/event` feed + per-type enablement + retention cron (#184) - - [ ] Event coverage remainder: share-link viewed, version ops, thumb-gen, metadata/view, mount, WebDAV acct, payment, account link/nick/avatar/password/2FA/passkey, abuse, OAuth grant - - [ ] site announcement modal + dismissal; group `allowed_nodes` + task `target_node`; `abuse_report` + admin queue + share context-menu Report entry + - [x] Coverage wave 2: email/user-activated/token-refresh/share-viewed/version/metadata/view/thumb/live-photo/copy-from/webdav/profile+security/oauth/admin-ops/import (1bbaddf) + - [ ] Event coverage remainder (needs unbuilt features): payment_*, link/unlink_account, membership_unsubscribe, report_abuse, mount, quota-notify + - [x] site announcement: `announcement` setting (markdown) + post-login modal + per-user dismissal re-triggering on content change (#184) + - [ ] group `allowed_nodes` + task `target_node`; `abuse_report` + admin queue + share context-menu Report entry ## 5. Phase C — security + quality diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index 8f0e94cf..25789c2c 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -1112,5 +1112,9 @@ ], "reportDescription": "Additional description", "reportAbuseSuccess": "Report submitted." + }, + "announcement": { + "title": "Announcement", + "dontShowAgain": "Don't show this again" } -} \ No newline at end of file +} diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 884bf3b5..03f406e2 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -1112,5 +1112,9 @@ ], "reportDescription": "补充说明", "reportAbuseSuccess": "举报已提交" + }, + "announcement": { + "title": "公告", + "dontShowAgain": "不再显示" } -} \ No newline at end of file +} diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 95c41ba8..590ffe41 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1333,6 +1333,22 @@ export function sendUploadAvatar(avatar?: Blob, contentType?: string): ThunkResp }; } +export function getAnnouncement(): ThunkResponse<{ content?: string }> { + return async (dispatch, _getState) => { + return await dispatch( + send( + `/user/setting/announcement`, + { + method: "GET", + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + export function sendUpdateUserSetting(settings: PatchUserSetting): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index f04f7811..c416728e 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -178,6 +178,8 @@ export interface PatchUserSetting { trash_retention?: number; // Preferred storage policy hash ID; "" inherits the group default. preferred_policy?: string; + // Marks the current site announcement as seen. + dismiss_announcement?: boolean; } export interface PasskeyCredentialOption { diff --git a/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx b/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx index 90b248af..e26b20c6 100644 --- a/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx +++ b/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx @@ -79,7 +79,13 @@ const SiteInformation = () => { - + setSettings({ announcement: e.target.value })} + value={values.announcement} + /> {t("settings.announcementDes")} diff --git a/frontend/src/component/FileManager/Dialogs/AnnouncementDialog.tsx b/frontend/src/component/FileManager/Dialogs/AnnouncementDialog.tsx new file mode 100644 index 00000000..62645c29 --- /dev/null +++ b/frontend/src/component/FileManager/Dialogs/AnnouncementDialog.tsx @@ -0,0 +1,85 @@ +import { LoadingButton } from "@mui/lab"; +import { Box, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Skeleton, useTheme } from "@mui/material"; +import { lazy, Suspense, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { getAnnouncement, sendUpdateUserSetting } from "../../../api/api.ts"; +import { useAppDispatch } from "../../../redux/hooks.ts"; + +const MarkdownEditor = lazy(() => import("../../Viewers/MarkdownEditor/Editor.tsx")); + +const Loading = () => ( + + + + +); + +// AnnouncementDialog fetches the current site announcement once on mount and +// shows it as a post-login modal. Dismissing with "don't show again" records +// the content server-side so the modal stays silent until the admin edits it. +const AnnouncementDialog = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const dispatch = useAppDispatch(); + const [content, setContent] = useState(); + const [dontShow, setDontShow] = useState(false); + const [closing, setClosing] = useState(false); + + useEffect(() => { + let mounted = true; + dispatch(getAnnouncement()) + .then((res) => { + if (mounted && res.content) { + setContent(res.content); + } + }) + .catch(() => {}); + return () => { + mounted = false; + }; + }, [dispatch]); + + const close = useCallback(() => { + if (dontShow) { + setClosing(true); + dispatch(sendUpdateUserSetting({ dismiss_announcement: true })) + .catch(() => {}) + .finally(() => setContent(undefined)); + } else { + setContent(undefined); + } + }, [dispatch, dontShow]); + + if (!content) { + return null; + } + + return ( + + {t("application:announcement.title")} + + }> + {}} + initialValue={content} + /> + + + + setDontShow(e.target.checked)} />} + label={t("application:announcement.dontShowAgain")} + /> + + {t("common:ok")} + + + + ); +}; + +export default AnnouncementDialog; diff --git a/frontend/src/component/FileManager/Dialogs/Dialogs.tsx b/frontend/src/component/FileManager/Dialogs/Dialogs.tsx index 7c3355e6..b38bbce0 100644 --- a/frontend/src/component/FileManager/Dialogs/Dialogs.tsx +++ b/frontend/src/component/FileManager/Dialogs/Dialogs.tsx @@ -12,6 +12,7 @@ import ManageShares from "./Share/ManageShares.tsx"; import StoragePolicy from "./StoragePolicy.tsx"; import AclDialog from "./Acl/AclDialog.tsx"; import ActivityDialog from "./Activity/ActivityDialog.tsx"; +import AnnouncementDialog from "./AnnouncementDialog.tsx"; import StaleVersionConfirm from "./StaleVersionConfirm.tsx"; import SaveAs from "./SaveAs.tsx"; import Photopea from "../../Viewers/Photopea/Photopea.tsx"; @@ -88,6 +89,7 @@ const Dialogs = () => { {archivePreview != undefined && } {desktopMountSetup != undefined && } + ); }; diff --git a/inventory/types/types.go b/inventory/types/types.go index 72add853..2de18076 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -31,6 +31,10 @@ type ( // PreferredPolicy is the user's default storage policy, chosen from // the policies allowed for their group. 0 means the group default. PreferredPolicy int `json:"preferred_policy,omitempty"` + // DismissedAnnouncement stores the announcement content the user last + // dismissed. When the admin edits the announcement it differs from + // this value and the modal is shown again. + DismissedAnnouncement string `json:"dismissed_announcement,omitempty"` } // LBPolicyRef binds a child storage policy to a load_balance policy with diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index 0140cbe7..bbdfe23d 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -263,6 +263,9 @@ type ( // AuditLogRetentionDays returns the audit retention window in days; // 0 keeps events forever. AuditLogRetentionDays(ctx context.Context) int + // Announcement returns the site-wide markdown announcement shown to + // logged-in users. "" disables the announcement modal. + Announcement(ctx context.Context) string } UseFirstSiteUrlCtxKey = struct{} ) @@ -742,6 +745,10 @@ func (s *settingProvider) AuditLogRetentionDays(ctx context.Context) int { return s.getInt(ctx, "audit_log_retention_days", 0) } +func (s *settingProvider) Announcement(ctx context.Context) string { + return s.getString(ctx, "announcement", "") +} + func (s *settingProvider) DefaultShares(ctx context.Context) []int { raw := s.getString(ctx, "default_symbolics", "") if raw == "" { diff --git a/routers/controllers/user.go b/routers/controllers/user.go index 969ec9f3..e36de3fe 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -251,6 +251,16 @@ func UpdateOption(c *gin.Context) { c.JSON(200, serializer.Response{}) } +// UserAnnouncement returns the current site announcement unless dismissed. +func UserAnnouncement(c *gin.Context) { + service := ParametersFromContext[*user.AnnouncementService](c, user.AnnouncementParamCtx{}) + res, err := service.Get(c) + if respondErr(c, err) { + return + } + c.JSON(200, serializer.Response{Data: res}) +} + // UserCredit returns the caller's credit balance and active grants. func UserCredit(c *gin.Context) { service := ParametersFromContext[*user.CreditService](c, user.CreditParamCtx{}) diff --git a/routers/router.go b/routers/router.go index 332e47b9..eacbd538 100644 --- a/routers/router.go +++ b/routers/router.go @@ -1445,6 +1445,11 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { { // 获取当前用户设定 setting.GET("", controllers.UserSetting) + // 当前公告(已忽略时为空) + setting.GET("announcement", + controllers.FromQuery[usersvc.AnnouncementService](usersvc.AnnouncementParamCtx{}), + controllers.UserAnnouncement, + ) // 从文件上传头像 setting.PUT("avatar", middleware.RequiredScopes(types.ScopeUserInfoWrite), controllers.UploadAvatar) // 更改用户设定 diff --git a/service/user/setting.go b/service/user/setting.go index 08c1632b..3c4c4923 100644 --- a/service/user/setting.go +++ b/service/user/setting.go @@ -225,6 +225,29 @@ func updateAvatarFile(ctx context.Context, u *ent.User, contentType string, file return nil } +// AnnouncementService serves the current site announcement to logged-in +// users, filtered by their dismissal record. +type AnnouncementService struct{} + +type AnnouncementParamCtx struct{} + +// Get returns the announcement content, or "" when unset or dismissed. +func (s *AnnouncementService) Get(c *gin.Context) (*AnnouncementResponse, error) { + dep := dependency.FromContext(c) + u := inventory.UserFromContext(c) + + current := dep.SettingProvider().Announcement(c) + if current == "" || current == u.Settings.DismissedAnnouncement { + return &AnnouncementResponse{}, nil + } + + return &AnnouncementResponse{Content: current}, nil +} + +type AnnouncementResponse struct { + Content string `json:"content,omitempty"` +} + type ( PatchUserSetting struct { Nick *string `json:"nick" binding:"omitempty,min=1,max=255"` @@ -250,6 +273,9 @@ type ( // PreferredPolicy selects the user's default storage policy from the // group's allowed set, hashid-encoded. "" clears the preference. PreferredPolicy *string `json:"preferred_policy" binding:"omitempty"` + // DismissAnnouncement records the current site announcement as seen + // so the modal does not show again until the content changes. + DismissAnnouncement *bool `json:"dismiss_announcement" binding:"omitempty"` } PatchUserSettingParamsCtx struct{} ) @@ -374,6 +400,11 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error { saveSetting = true } + if s.DismissAnnouncement != nil && *s.DismissAnnouncement { + u.Settings.DismissedAnnouncement = dep.SettingProvider().Announcement(c) + saveSetting = true + } + if s.CurrentPassword != nil && s.NewPassword != nil { if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil { return err