added messages templates

This commit is contained in:
onysd 2026-09-01 14:40:06 +03:00
parent e8dc967e6a
commit 7cd1f64d0d
29 changed files with 1266 additions and 84 deletions

View file

@ -116,6 +116,21 @@ type uiConfig struct {
// directory owpengram-server reads, so an identity edit here is visible
// over /owpengram/server-info immediately (see internal/identity).
IdentityDir string
// WelcomeMessagePhoneDefault/WelcomeMessageEmailDefault mirror
// config.WelcomeMessage{Phone,Email}Template -- the env-var-resolved
// fallback text (TELESRV_WELCOME_MESSAGE_*_TEMPLATE, itself defaulting
// to the compiled-in copy) the running owpengram-server process falls
// back to whenever the identity panel override is unset. Surfaced as
// "the effective default" in the Server Settings login-notifications
// panel, assuming both binaries share the same .env.
WelcomeMessagePhoneDefault string
WelcomeMessageEmailDefault string
// LoginCodeMessageDefault mirrors config.LoginCodeMessageTemplate -- the
// env-var-resolved fallback text (TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE,
// itself defaulting to the compiled-in copy) the running owpengram-server
// process falls back to whenever the identity panel override is unset.
// Same "effective default" contract as WelcomeMessage{Phone,Email}Default.
LoginCodeMessageDefault string
// RepoRoot is where Server Settings' Restart/Update/.env-editing (see
// internal/procctl) operate: bin/, logs/, .env, .env.example and
// .server_panel.json are all expected directly under it, exactly as
@ -167,6 +182,9 @@ func loadConfig() (uiConfig, error) {
Permissions: appCfg.AdminUIPermissions,
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
IdentityDir: appCfg.IdentityDir,
WelcomeMessagePhoneDefault: appCfg.WelcomeMessagePhoneTemplate,
WelcomeMessageEmailDefault: appCfg.WelcomeMessageEmailTemplate,
LoginCodeMessageDefault: appCfg.LoginCodeMessageTemplate,
RepoRoot: repoRoot,
}, nil
}

View file

@ -178,6 +178,8 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/server/identity", s.serverManage(s.handleServerIdentityAPI))
mux.Handle("GET /api/server/icon", s.serverManage(s.handleServerIconAPI))
mux.Handle("POST /api/actions/set-server-identity", s.serverManage(s.handleSetServerIdentityAPI))
mux.Handle("POST /api/actions/set-welcome-message-templates", s.serverManage(s.handleSetWelcomeMessageTemplatesAPI))
mux.Handle("POST /api/actions/set-login-code-message-template", s.serverManage(s.handleSetLoginCodeMessageTemplateAPI))
mux.Handle("POST /api/actions/upload-server-icon", s.serverManage(s.handleUploadServerIconAPI))
mux.Handle("POST /api/actions/remove-server-icon", s.serverManage(s.handleRemoveServerIconAPI))
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))

View file

@ -8,6 +8,8 @@ import (
"strings"
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/identity"
)
// serverManage gates the whole Server Settings surface -- see
@ -47,13 +49,39 @@ func serverCommandResult(meta admin.CommandMeta, action string, err error, messa
// --- identity (name/description/icon) ---------------------------------
// serverIdentityAPIResponse extends identity.Info's raw fields with the two
// *effective* fallback welcome-message templates -- s.cfg's
// WelcomeMessage{Phone,Email}Default, i.e. this admin process's own reading
// of TELESRV_WELCOME_MESSAGE_*_TEMPLATE (env var, itself defaulting to the
// compiled-in copy), which matches what owpengram-server falls back to
// whenever the panel override is unset, as long as both processes share the
// same .env (see uiConfig.WelcomeMessagePhoneDefault's doc comment). The
// panel needs both: the raw override (possibly empty) to know whether a
// field is "explicitly set", and the default text to show as "(using
// default: ...)" / to restore on Reset.
type serverIdentityAPIResponse struct {
identity.Info
DefaultWelcomeMessagePhoneTemplate string `json:"default_welcome_message_phone_template"`
DefaultWelcomeMessageEmailTemplate string `json:"default_welcome_message_email_template"`
// DefaultLoginCodeMessageTemplate is the effective fallback text for
// the login-code delivery message (s.cfg.LoginCodeMessageDefault) --
// same "raw override + effective default" contract as the two fields
// above, see their doc comment.
DefaultLoginCodeMessageTemplate string `json:"default_login_code_message_template"`
}
func (s *server) handleServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
info, err := s.identity.Get()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, info)
writeJSON(w, http.StatusOK, serverIdentityAPIResponse{
Info: info,
DefaultWelcomeMessagePhoneTemplate: s.cfg.WelcomeMessagePhoneDefault,
DefaultWelcomeMessageEmailTemplate: s.cfg.WelcomeMessageEmailDefault,
DefaultLoginCodeMessageTemplate: s.cfg.LoginCodeMessageDefault,
})
}
// handleServerIconAPI serves the icon's raw bytes for the panel's own
@ -101,6 +129,84 @@ func (s *server) handleSetServerIdentityAPI(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", err, "server identity updated", details))
}
// --- login-notification templates ---------------------------------------
type setWelcomeMessageTemplatesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
PhoneTemplate string `json:"phone_template"`
EmailTemplate string `json:"email_template"`
}
// handleSetWelcomeMessageTemplatesAPI sets (or, with an empty string,
// clears) the admin-panel override for the 777000 login-notification
// message's phone/email template -- see identity.Store.SetWelcomeMessageTemplates
// and domain.ResolveWelcomeMessageTemplate. Deliberately a separate endpoint
// from set-server-identity: brand identity (name/description/icon) and
// login-notification copy are different concerns that happen to share the
// same on-disk identity.json, and keeping them as separate actions/buttons
// means editing one never risks silently blanking the other.
func (s *server) handleSetWelcomeMessageTemplatesAPI(w http.ResponseWriter, r *http.Request) {
var body setWelcomeMessageTemplatesAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-welcome-message-templates")
details := map[string]any{
"phone_template_set": strings.TrimSpace(body.PhoneTemplate) != "",
"email_template_set": strings.TrimSpace(body.EmailTemplate) != "",
}
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_welcome_message_templates", nil, "login-notification templates validated", details))
return
}
err := s.identity.SetWelcomeMessageTemplates(body.PhoneTemplate, body.EmailTemplate)
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_welcome_message_templates", err, "login-notification templates updated", details))
}
// --- login-code delivery message template --------------------------------
type setLoginCodeMessageTemplateAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Template string `json:"template"`
}
// handleSetLoginCodeMessageTemplateAPI sets (or, with an empty string,
// clears) the admin-panel override for the 777000 login-code delivery
// message -- see identity.Store.SetLoginCodeMessageTemplate and
// domain.ResolveLoginCodeMessageTemplate. A dedicated endpoint (not folded
// into set-welcome-message-templates): this message embeds the actual OTP
// code via the {{code}} placeholder, so a save here carries an extra,
// security-relevant validation the login-notification templates don't
// need -- a template missing {{code}} (or containing it more than once)
// would either silently drop the code from the message or leave it
// ambiguous which occurrence carries it, so it is rejected outright with a
// 422 rather than saved. Clearing the override (empty string) is exempt --
// it always resolves to a valid built-in/env default.
func (s *server) handleSetLoginCodeMessageTemplateAPI(w http.ResponseWriter, r *http.Request) {
var body setLoginCodeMessageTemplateAPIRequest
if !decodeAction(w, r, &body) {
return
}
if t := strings.TrimSpace(body.Template); t != "" {
if err := domain.ValidateLoginCodeMessageTemplate(t); err != nil {
writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
return
}
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-login-code-message-template")
details := map[string]any{"template_set": strings.TrimSpace(body.Template) != ""}
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_login_code_message_template", nil, "login-code message template validated", details))
return
}
err := s.identity.SetLoginCodeMessageTemplate(body.Template)
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_login_code_message_template", err, "login-code message template updated", details))
}
var allowedServerIconExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true,
}

View file

@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"telesrv/internal/identity"
)
func TestServerIdentityAPIReportsOverridesAndDefaultsSeparately(t *testing.T) {
store := identity.NewStore(t.TempDir())
srv := &server{
cfg: uiConfig{
WelcomeMessagePhoneDefault: "env phone default",
WelcomeMessageEmailDefault: "env email default",
},
identity: store,
}
// Before any override: GET must report empty raw fields (so the UI can
// tell "unset" apart from "explicitly set to the same text as the
// default"), alongside the effective default text.
req := httptest.NewRequest(http.MethodGet, "/api/server/identity", nil)
rec := httptest.NewRecorder()
srv.handleServerIdentityAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp serverIdentityAPIResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.WelcomeMessagePhoneTemplate != "" || resp.WelcomeMessageEmailTemplate != "" {
t.Fatalf("expected empty overrides before any Set, got %+v", resp)
}
if resp.DefaultWelcomeMessagePhoneTemplate != "env phone default" || resp.DefaultWelcomeMessageEmailTemplate != "env email default" {
t.Fatalf("expected effective defaults from cfg, got %+v", resp)
}
// Set an override for phone only.
setReq := httptest.NewRequest(http.MethodPost, "/api/actions/set-welcome-message-templates", strings.NewReader(`{
"reason": "test",
"confirm": true,
"phone_template": "custom phone template"
}`))
setRec := httptest.NewRecorder()
srv.handleSetWelcomeMessageTemplatesAPI(setRec, setReq)
if setRec.Code != http.StatusOK {
t.Fatalf("SET status = %d, body = %s", setRec.Code, setRec.Body.String())
}
req2 := httptest.NewRequest(http.MethodGet, "/api/server/identity", nil)
rec2 := httptest.NewRecorder()
srv.handleServerIdentityAPI(rec2, req2)
var resp2 serverIdentityAPIResponse
if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("decode: %v", err)
}
if resp2.WelcomeMessagePhoneTemplate != "custom phone template" {
t.Fatalf("expected phone override to be set, got %+v", resp2)
}
if resp2.WelcomeMessageEmailTemplate != "" {
t.Fatalf("expected email override to stay unset, got %+v", resp2)
}
// Reset (empty string) clears the override back to "unset".
resetReq := httptest.NewRequest(http.MethodPost, "/api/actions/set-welcome-message-templates", strings.NewReader(`{
"reason": "test",
"confirm": true,
"phone_template": ""
}`))
resetRec := httptest.NewRecorder()
srv.handleSetWelcomeMessageTemplatesAPI(resetRec, resetReq)
if resetRec.Code != http.StatusOK {
t.Fatalf("reset status = %d, body = %s", resetRec.Code, resetRec.Body.String())
}
info, err := store.Get()
if err != nil {
t.Fatal(err)
}
if info.WelcomeMessagePhoneTemplate != "" {
t.Fatalf("expected phone override cleared after reset, got %+v", info)
}
}

View file

@ -23,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-TfcI68oK.js"></script>
<script type="module" crossorigin src="/assets/index-BTi0-ijr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-0MvM-hpw.css">
</head>
<body>

View file

@ -30,6 +30,7 @@ export function ServerSettingsPage() {
{tab === "settings" ? (
<div className="stacked-sections">
<IdentitySection />
<LoginNotificationsSection />
<EnvSection />
</div>
) : (
@ -127,6 +128,168 @@ function IdentitySection() {
);
}
// --- Login notifications ------------------------------------------------
// LoginNotificationsSection edits the 777000 login-notification message's
// per-method (phone/email) template -- a different concern from brand
// identity above even though both live in the same identity.json (see
// cmd/telesrv-admin/serversettings.go's handleSetWelcomeMessageTemplatesAPI
// doc comment), so it gets its own card and its own save action.
function LoginNotificationsSection() {
const [identity, setIdentity] = useState<ServerIdentity | null>(null);
const [phoneTemplate, setPhoneTemplate] = useState("");
const [emailTemplate, setEmailTemplate] = useState("");
const [codeTemplate, setCodeTemplate] = useState("");
const [error, setError] = useState("");
async function load() {
setError("");
try {
const info = await api.serverIdentity();
setIdentity(info);
setPhoneTemplate(info.welcome_message_phone_template ?? "");
setEmailTemplate(info.welcome_message_email_template ?? "");
setCodeTemplate(info.login_code_message_template ?? "");
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
const phoneIsOverridden = phoneTemplate.trim() !== "";
const emailIsOverridden = emailTemplate.trim() !== "";
const codeIsOverridden = codeTemplate.trim() !== "";
// Mirrors the server-side check in handleSetLoginCodeMessageTemplateAPI --
// disable the save button instead of letting the operator submit a
// template that would silently never deliver the actual OTP code.
const codeOccurrences = (codeTemplate.match(/\{\{code\}\}/g) ?? []).length;
const codeTemplateInvalid = codeIsOverridden && codeOccurrences !== 1;
return (
<section className="section-block">
<SectionHead title={"Login notifications"} />
{error && <Alert>{error}</Alert>}
{!identity ? (
<LoadingSurface label={"Loading login notification templates..."} />
) : (
<div className="card-body">
<p style={{ color: "var(--muted)", marginTop: 0 }}>
{"Sent from the official system account on every completed sign-in. Use "}
<code>{"{{server_name}}"}</code>
{" to insert the server's configured name."}
</p>
<label className="form-field">
<span>
{"Phone sign-in template"}
{" "}
{phoneIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
</span>
<textarea
rows={4}
value={phoneTemplate}
onChange={(event) => setPhoneTemplate(event.target.value)}
placeholder={identity.default_welcome_message_phone_template}
/>
</label>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
compact
label={"Reset to default"}
path="/api/actions/set-welcome-message-templates"
payload={() => ({ phone_template: "", email_template: emailTemplate })}
disabled={!phoneIsOverridden}
onDone={() => { setPhoneTemplate(""); void load(); }}
/>
</div>
<label className="form-field">
<span>
{"Email sign-in template"}
{" "}
{emailIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
</span>
<textarea
rows={4}
value={emailTemplate}
onChange={(event) => setEmailTemplate(event.target.value)}
placeholder={identity.default_welcome_message_email_template}
/>
</label>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
compact
label={"Reset to default"}
path="/api/actions/set-welcome-message-templates"
payload={() => ({ phone_template: phoneTemplate, email_template: "" })}
disabled={!emailIsOverridden}
onDone={() => { setEmailTemplate(""); void load(); }}
/>
</div>
<div className="gift-table-actions identity-save-row">
<ActionButton
tone="neutral"
label={"Save login notification templates"}
path="/api/actions/set-welcome-message-templates"
payload={() => ({ phone_template: phoneTemplate, email_template: emailTemplate })}
onDone={() => void load()}
/>
</div>
<p style={{ color: "var(--muted)", marginTop: "1.5em", borderTop: "1px solid var(--line)", paddingTop: "1em" }}>
{"Sent from the official system account with every login code (SMS and email alike). Must contain "}
<code>{"{{code}}"}</code>
{" exactly once -- that's where the actual code is inserted and bolded. "}
<code>{"{{server_name}}"}</code>
{" is optional and may appear any number of times."}
</p>
<label className="form-field">
<span>
{"Login-code message template"}
{" "}
{codeIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
</span>
<textarea
rows={5}
value={codeTemplate}
onChange={(event) => setCodeTemplate(event.target.value)}
placeholder={identity.default_login_code_message_template}
/>
{codeTemplateInvalid && (
<span style={{ color: "var(--danger-text)", fontSize: "0.85em" }}>
{codeOccurrences === 0
? "Must contain {{code}} exactly once -- it is currently missing."
: `Must contain {{code}} exactly once -- it currently appears ${codeOccurrences} times.`}
</span>
)}
</label>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
compact
label={"Reset to default"}
path="/api/actions/set-login-code-message-template"
payload={() => ({ template: "" })}
disabled={!codeIsOverridden}
onDone={() => { setCodeTemplate(""); void load(); }}
/>
</div>
<div className="gift-table-actions identity-save-row">
<ActionButton
tone="neutral"
label={"Save login-code message template"}
path="/api/actions/set-login-code-message-template"
payload={() => ({ template: codeTemplate })}
disabled={codeTemplateInvalid}
onDone={() => void load()}
/>
</div>
</div>
)}
</section>
);
}
function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");

View file

@ -851,6 +851,26 @@ export type ServerIdentity = {
name: string;
description: string;
icon_ext?: string;
// welcome_message_*_template: raw admin-panel override for the 777000
// login-notification message, empty when unset (falls back to the
// TELESRV_WELCOME_MESSAGE_*_TEMPLATE env var, then a built-in default).
welcome_message_phone_template?: string;
welcome_message_email_template?: string;
// default_welcome_message_*_template: the effective fallback text this
// admin process currently reads (env var if set, else the compiled-in
// copy) -- shown when the override above is empty.
default_welcome_message_phone_template: string;
default_welcome_message_email_template: string;
// login_code_message_template: raw admin-panel override for the 777000
// login-code delivery message, empty when unset (falls back to the
// TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE env var, then a built-in default).
// Unlike the welcome_message_* templates there is only one -- the
// message never varies by delivery channel. Must contain the {{code}}
// placeholder exactly once (enforced server-side on save).
login_code_message_template?: string;
// default_login_code_message_template: the effective fallback text this
// admin process currently reads -- shown when the override above is empty.
default_login_code_message_template: string;
};
export type EnvField = {

View file

@ -1401,6 +1401,8 @@ func run(logger *zap.Logger) error {
)
authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
auth.WithLoginMessages(messageStore, dialogStore),
auth.WithLoginWelcomeMessages(identityStore, cfg.WelcomeMessagePhoneTemplate, cfg.WelcomeMessageEmailTemplate),
auth.WithLoginCodeMessageTemplate(identityStore, cfg.LoginCodeMessageTemplate),
auth.WithLoginCodeDelivery(messageStore),
auth.WithPasswords(passwordStore),
auth.WithBotLogin(botStore),