feat: site announcements with per-user dismissal

Admin writes a markdown announcement in Site settings; logged-in
users see it as a post-login modal rendered read-only. "Don't show
again" stores the content in user settings — editing the text
re-shows it to everyone.

- setting.Provider.Announcement + announcement key (wired the dead
  Pro scaffold field)
- UserSetting.dismissed_announcement + dismiss_announcement PATCH flag
- GET /user/setting/announcement (empty when dismissed/unset)
- AnnouncementDialog in the file-manager dialog host (lazy MDXEditor)
- en-US + zh-CN locales; roadmap status

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3587/head
Tomas Dvorak 2 weeks ago
parent 1bbaddf832
commit f7a90a5928

@ -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

@ -1112,5 +1112,9 @@
],
"reportDescription": "Additional description",
"reportAbuseSuccess": "Report submitted."
},
"announcement": {
"title": "Announcement",
"dontShowAgain": "Don't show this again"
}
}

@ -1112,5 +1112,9 @@
],
"reportDescription": "补充说明",
"reportAbuseSuccess": "举报已提交"
},
"announcement": {
"title": "公告",
"dontShowAgain": "不再显示"
}
}

@ -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(

@ -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 {

@ -79,7 +79,13 @@ const SiteInformation = () => {
</SettingForm>
<SettingForm title={t("settings.announcement")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField inputProps={{ readOnly: true }} fullWidth multiline rows={4} />
<DenseFilledTextField
fullWidth
multiline
rows={4}
onChange={(e) => setSettings({ announcement: e.target.value })}
value={values.announcement}
/>
<NoMarginHelperText>{t("settings.announcementDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>

@ -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 = () => (
<Box>
<Skeleton variant="text" width="100%" height={24} />
<Skeleton variant="text" width="60%" height={24} />
</Box>
);
// 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<string | undefined>();
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 (
<Dialog open onClose={close} maxWidth="sm" fullWidth>
<DialogTitle>{t("application:announcement.title")}</DialogTitle>
<DialogContent dividers>
<Suspense fallback={<Loading />}>
<MarkdownEditor
displayOnly
value={content}
darkMode={theme.palette.mode === "dark"}
readOnly={true}
onChange={() => {}}
initialValue={content}
/>
</Suspense>
</DialogContent>
<DialogActions sx={{ justifyContent: "space-between" }}>
<FormControlLabel
control={<Checkbox checked={dontShow} onChange={(e) => setDontShow(e.target.checked)} />}
label={t("application:announcement.dontShowAgain")}
/>
<LoadingButton loading={closing} variant="contained" onClick={close}>
{t("common:ok")}
</LoadingButton>
</DialogActions>
</Dialog>
);
};
export default AnnouncementDialog;

@ -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 = () => {
<StoragePolicy />
{archivePreview != undefined && <ArchivePreview />}
{desktopMountSetup != undefined && <DesktopMountSetup />}
<AnnouncementDialog />
</>
);
};

@ -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

@ -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 == "" {

@ -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{})

@ -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)
// 更改用户设定

@ -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

Loading…
Cancel
Save