diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d58ce0dd..54ec728f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -141,6 +141,33 @@ System administrators see a **bug icon** in the web top bar (`features.debugMenu With developer mode on, the top-bar **bug menu** shows sectioned actions (Record, User interface, Security, Tools). Each form field label gets an **info icon**; hover it for a technical popover (field, model, domain, modifiers). **Metadata** and **Data** open modals; **Access rights** opens the secondary debug drawer (collapsed by default). The **field inspector** toggle enables click-to-select on the field widget without blocking normal input when off. **SWC Vision** and **Open metrics** remain admin-gated. +## Realtime bus channels (SWC WebSocket) + +Clients connect to `GET /web/swc/bus` (session cookie). Wire frames are JSON: + +- Client → server: `subscribe` / `unsubscribe` (with `channels`, optional `last_event_id`), `ping`. +- Server → client: `event` (`id`, `channel`, `payload`), `pong`, `error`. + +**Channel ACL** (server enforces before subscribe): + +| Prefix | Rule | +|--------|------| +| `user/{uid}/…` | Session uid must match `{uid}` | +| `group/{xmlid}` | User must belong to group | +| `record/{model}/{id}` | Read access on record | +| `company/{id}` | User allowed company | +| `model/{model}` | Model read ACL | + +Events are persisted in `sys.bus.event`; PostgreSQL `NOTIFY sumeru_bus` fan-out supports multiple app processes. Payloads carry record ids only — never field values from elevated writes. + +## Mail thread and notifications + +Models with `mail_thread` on the embedded model tag auto-subscribe creator and common assignee fields on create. Chatter uses `mail.message` subtypes (`mail.message.subtype`). Followers (`mail.follower`) drive `mail.notification` rows and optional HTML email via the mail queue. @mentions in chatter bodies notify mentioned users by login. + +## Auth providers and MFA + +Configure IdPs under **Settings → Security → Authentication providers** (`sys.auth.provider`; PKCE on start/callback). **Linked identities** lists `core.user.identity` rows. The login page shows enabled providers as SSO buttons. Local password login stays available unless system parameter `auth.local_enabled` is `false` and at least one provider is enabled. TOTP: users enroll under **Settings → Account security**; login uses `totp_enabled` / `totp_secret` with an HMAC-signed pending-MFA cookie before session creation; trusted devices use a signed cookie bound to the user id. SAML is not implemented — use OIDC-capable IdPs. When `jwks_url` is set, the callback verifies the `id_token` signature against JWKS (RS256/ES256) before linking the user. + ## View modifier expressions (SWC) Dynamic `invisible` / `readonly` / `required` expressions in form and list arch are evaluated client-side with a **frozen allowlist** of identifiers: record field names, `user_id`, `company_id`, and `context` (object). Expressions must be boolean JavaScript fragments (for example `state == 'done'`), not statements. Tokens such as `function`, `=>`, `[`, `` ` ``, or `;` are rejected. Static arch flags still apply when an expression is missing or invalid. List column expressions that reference record fields are evaluated without a row context (static arch flags apply). Action `context` on the workspace payload is not wired yet — `context` is an empty object until then. diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 70e95540..27155d56 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -21,6 +21,10 @@ "views/sys_report_action_list_views.xml", "views/sys_report_action_form_views.xml", "views/security_actions.xml", + "views/auth_actions.xml", + "views/sys_auth_provider_list_views.xml", + "views/sys_auth_provider_form_views.xml", + "views/core_user_identity_list_views.xml", "views/core_company_form_views.xml", "views/core_company_kanban_views.xml", "views/core_company_list_views.xml", diff --git a/addons/base/models/core_user_identity.go b/addons/base/models/core_user_identity.go new file mode 100644 index 00000000..e3fe60a3 --- /dev/null +++ b/addons/base/models/core_user_identity.go @@ -0,0 +1,13 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type CoreUserIdentity struct { + sdk.Model `sumeru:"model=core.user.identity"` + + ProviderID sdk.Many2One[SysAuthProvider] `sumeru:"required,index,string=Provider"` + Subject sdk.String `sumeru:"required,index,string=Subject"` + UserID sdk.Many2One[CoreUser] `sumeru:"required,index,string=User"` +} diff --git a/addons/base/models/core_user_trusteddevice.go b/addons/base/models/core_user_trusteddevice.go new file mode 100644 index 00000000..cd18ea61 --- /dev/null +++ b/addons/base/models/core_user_trusteddevice.go @@ -0,0 +1,14 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type CoreUserTrustedDevice struct { + sdk.Model `sumeru:"model=core.user.trusteddevice"` + + UserID sdk.Many2One[CoreUser] `sumeru:"required,index,string=User"` + TokenHash sdk.String `sumeru:"required,index,string=Token Hash,column=token_hash"` + UserAgent sdk.String `sumeru:"string=User Agent,column=user_agent"` + ExpiresAt sdk.DateTime `sumeru:"required,index,string=Expires At,column=expires_at"` +} diff --git a/addons/base/models/sys_auth_provider.go b/addons/base/models/sys_auth_provider.go new file mode 100644 index 00000000..68717088 --- /dev/null +++ b/addons/base/models/sys_auth_provider.go @@ -0,0 +1,22 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type SysAuthProvider struct { + sdk.Model `sumeru:"model=sys.auth.provider"` + + Name sdk.String `sumeru:"required,string=Name"` + ProviderType sdk.String `sumeru:"required,string=Type,default=oidc,selection=oidc:OpenID Connect,oauth2:OAuth2"` + ClientID sdk.String `sumeru:"required,string=Client ID"` + ClientSecret sdk.String `sumeru:"string=Client Secret"` + IssuerURL sdk.String `sumeru:"string=Issuer URL"` + AuthorizeURL sdk.String `sumeru:"string=Authorize URL"` + TokenURL sdk.String `sumeru:"string=Token URL"` + JwksURL sdk.String `sumeru:"string=JWKS URL"` + Scopes sdk.String `sumeru:"string=Scopes,default=openid email profile"` + Enabled sdk.Boolean `sumeru:"string=Enabled,default=false"` + ButtonLabel sdk.String `sumeru:"string=Button Label"` + LinkPolicy sdk.String `sumeru:"string=Unknown Users,default=deny,selection=deny:Deny,link:Link by email,jit_internal:JIT internal,jit_portal:JIT portal"` +} diff --git a/addons/base/models/zmodels.go b/addons/base/models/zmodels.go index 300693b5..5150e402 100644 --- a/addons/base/models/zmodels.go +++ b/addons/base/models/zmodels.go @@ -16,10 +16,13 @@ func init() { &CorePartner{}, &CoreUser{}, &CoreUserAPIKey{}, + &CoreUserIdentity{}, &CoreUserLog{}, + &CoreUserTrustedDevice{}, &ResConfigSettings{}, &SysAttachment{}, &SysAudit{}, + &SysAuthProvider{}, &SysBulkImport{}, &SysConfigParameter{}, &SysFieldAccess{}, diff --git a/addons/base/views/auth_actions.xml b/addons/base/views/auth_actions.xml new file mode 100644 index 00000000..a06e99df --- /dev/null +++ b/addons/base/views/auth_actions.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/addons/base/views/core_user_identity_list_views.xml b/addons/base/views/core_user_identity_list_views.xml new file mode 100644 index 00000000..a5f3bd67 --- /dev/null +++ b/addons/base/views/core_user_identity_list_views.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/base/views/core_users_form_views.xml b/addons/base/views/core_users_form_views.xml index 70a36700..c9c9c920 100644 --- a/addons/base/views/core_users_form_views.xml +++ b/addons/base/views/core_users_form_views.xml @@ -55,7 +55,7 @@ diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index 9349c2b9..04e59d21 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -20,6 +20,8 @@ + + diff --git a/addons/base/views/sys_auth_provider_form_views.xml b/addons/base/views/sys_auth_provider_form_views.xml new file mode 100644 index 00000000..15ad985d --- /dev/null +++ b/addons/base/views/sys_auth_provider_form_views.xml @@ -0,0 +1,41 @@ + + + + + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/addons/base/views/sys_auth_provider_list_views.xml b/addons/base/views/sys_auth_provider_list_views.xml new file mode 100644 index 00000000..10d451f3 --- /dev/null +++ b/addons/base/views/sys_auth_provider_list_views.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/mail/data/mail_subtype_data.xml b/addons/mail/data/mail_subtype_data.xml new file mode 100644 index 00000000..dd805b0c --- /dev/null +++ b/addons/mail/data/mail_subtype_data.xml @@ -0,0 +1,16 @@ + + + + + comment + User discussion + True + + + notification + System notification + True + True + + + diff --git a/addons/mail/followers.go b/addons/mail/followers.go new file mode 100644 index 00000000..2b75dca6 --- /dev/null +++ b/addons/mail/followers.go @@ -0,0 +1,171 @@ +package mail + +import ( + "context" + "fmt" + "html" + "regexp" + "strings" + + pkgmail "sumeru/core/mail" + "sumeru/core/orm" + "time" +) + +var mentionPattern = regexp.MustCompile(`@([a-zA-Z0-9._-]+)`) + +// SubscribeUserFollower ensures user_id follows the record (idempotent). +func SubscribeUserFollower(ctx context.Context, model string, resID int64, userID int) error { + if userID <= 0 || resID <= 0 || strings.TrimSpace(model) == "" { + return nil + } + if _, ok := orm.Registry["mail.follower"]; !ok { + return nil + } + existing, err := orm.SearchOne(ctx, "mail.follower", map[string]interface{}{ + "res_model": model, + "res_id": int(resID), + "user_id": userID, + }) + if err == nil && existing != nil { + return nil + } + inst := orm.Registry["mail.follower"] + _, err = orm.Create(ctx, inst, map[string]interface{}{ + "res_model": model, + "res_id": int(resID), + "user_id": userID, + "active": true, + }) + return err +} + +// NotifyMessageFollowers creates inbox notifications and optional email for a posted message. +func NotifyMessageFollowers(ctx context.Context, messageID int, authorUserID int) error { + if messageID <= 0 || orm.DB == nil { + return nil + } + msg, err := orm.SearchOne(ctx, "mail.message", map[string]interface{}{"id": messageID}) + if err != nil || msg == nil { + return err + } + model := rowString(msg, "model") + resID := int(rowInt64(msg, "core_id")) + subtype := rowString(msg, "subtype") + body := rowString(msg, "body") + if model == "" || resID <= 0 { + return nil + } + recipients := map[int]struct{}{} + followers, _ := orm.Search(ctx, "mail.follower", [][]interface{}{ + {"res_model", "=", model}, + {"res_id", "=", resID}, + {"active", "=", true}, + }) + for _, f := range followers { + uid := int(rowInt64(f, "user_id")) + if uid <= 0 || uid == authorUserID { + continue + } + recipients[uid] = struct{}{} + } + for _, login := range mentionPattern.FindAllStringSubmatch(body, -1) { + if len(login) < 2 { + continue + } + uid := userIDByLogin(ctx, login[1]) + if uid > 0 && uid != authorUserID { + recipients[uid] = struct{}{} + _ = SubscribeUserFollower(ctx, model, int64(resID), uid) + } + } + authorName := rowString(msg, "author") + subject := fmt.Sprintf("[%s] %s", model, truncate(body, 80)) + for uid := range recipients { + if err := orm.CheckModelAccess(ctx, uid, model, "read"); err != nil { + continue + } + notifID, err := createNotification(ctx, uid, messageID, model, resID) + if err != nil { + continue + } + orm.PublishBusChannel(ctx, orm.UserNotificationsBusChannel(uid), map[string]interface{}{ + "notification_id": notifID, + "message_id": messageID, + "model": model, + "id": resID, + }) + if shouldEmailUser(ctx, uid) { + email := userEmail(ctx, uid) + if email != "" { + htmlBody := renderNotificationHTML(authorName, body, model, resID) + pkgmail.EnqueueHTML(ctx, email, subject, htmlBody, stripHTML(body)) + } + } + } + _ = subtype + return nil +} + +func createNotification(ctx context.Context, uid, messageID int, model string, resID int) (int, error) { + inst := orm.Registry["mail.notification"] + id, err := orm.Create(ctx, inst, map[string]interface{}{ + "user_id": uid, + "message_id": messageID, + "is_read": false, + "record_model": model, + "record_id": resID, + "create_date": time.Now().UTC().Format(time.RFC3339), + }) + return id, err +} + +func userIDByLogin(ctx context.Context, login string) int { + login = strings.TrimSpace(login) + if login == "" || orm.DB == nil { + return 0 + } + rec, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"login": login}) + if err != nil || rec == nil { + return 0 + } + return int(rowInt64(rec, "id")) +} + +func userEmail(ctx context.Context, uid int) string { + rec, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": uid}) + if err != nil || rec == nil { + return "" + } + return rowString(rec, "email") +} + +func shouldEmailUser(ctx context.Context, uid int) bool { + rec, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": uid}) + if err != nil || rec == nil { + return false + } + if v, ok := rec["notify_email"].(bool); ok { + return v + } + return true +} + +func renderNotificationHTML(author, body, model string, resID int) string { + esc := html.EscapeString(body) + authorEsc := html.EscapeString(author) + return "

" + authorEsc + " on " + html.EscapeString(model) + + " #" + fmt.Sprint(resID) + ":

" + strings.ReplaceAll(esc, "\n", "
") + "

" +} + +func stripHTML(s string) string { + return strings.TrimSpace(s) +} + +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/addons/mail/hooks.go b/addons/mail/hooks.go new file mode 100644 index 00000000..161d9089 --- /dev/null +++ b/addons/mail/hooks.go @@ -0,0 +1,61 @@ +package mail + +import ( + "context" + + "sumeru/core/event" + "sumeru/core/orm" +) + +func init() { + event.Subscribe(eventRecordCreated, onMailThreadRecordCreated) + event.Subscribe(eventRecordUpdated, onMailThreadRecordUpdated) +} + +const ( + eventRecordCreated = "record.created" + eventRecordUpdated = "record.updated" +) + +func onMailThreadRecordCreated(ctx context.Context, ev event.Event) error { + model, _ := ev.Payload["model"].(string) + id, _ := payloadInt(ev.Payload["id"]) + if model == "" || id <= 0 || !orm.ModelHasMailThread(model) { + return nil + } + uid := ev.Actor + if uid <= 0 { + uid = orm.SecurityUID(ctx) + } + return SubscribeUserFollower(ctx, model, int64(id), uid) +} + +func onMailThreadRecordUpdated(ctx context.Context, ev event.Event) error { + model, _ := ev.Payload["model"].(string) + id, _ := payloadInt(ev.Payload["id"]) + if model == "" || id <= 0 || !orm.ModelHasMailThread(model) { + return nil + } + rec, err := orm.SearchOne(ctx, model, map[string]interface{}{"id": id}) + if err != nil || rec == nil { + return nil + } + assignee, ok := payloadInt(rec["user_id"]) + if !ok || assignee <= 0 { + return nil + } + return SubscribeUserFollower(ctx, model, int64(id), assignee) +} + +func payloadInt(v interface{}) (int, bool) { + switch t := v.(type) { + case int: + return t, true + case int64: + return int(t), true + case float64: + return int(t), true + default: + return 0, false + } +} diff --git a/addons/mail/mail.go b/addons/mail/mail.go index a83b8a9a..596d6b23 100644 --- a/addons/mail/mail.go +++ b/addons/mail/mail.go @@ -123,8 +123,14 @@ func PostMessage(ctx context.Context, model string, coreID int64, body, subtype, if settings, ok := firstCompanyMailSettings(ctx); ok && settings.id > 0 { vals["company_id"] = int(settings.id) } - _, err := orm.Create(ctx, inst, vals) - return err + msgID, err := orm.Create(ctx, inst, vals) + if err != nil { + return err + } + if subtype == SubtypeComment { + _ = NotifyMessageFollowers(ctx, msgID, uid) + } + return nil } // ListCommentsForRecord returns user chatter lines (subtype comment) for a record, oldest first. diff --git a/addons/mail/manifest.json b/addons/mail/manifest.json index a613ef6e..e91f82eb 100644 --- a/addons/mail/manifest.json +++ b/addons/mail/manifest.json @@ -10,6 +10,7 @@ "security/security.xml", "security/sys.access.csv", "data/mail_activity_type_data.xml", + "data/mail_subtype_data.xml", "views/actions.xml", "views/mail_message_list_views.xml", "views/mail_message_form_views.xml", diff --git a/addons/mail/models/core_user_mail.go b/addons/mail/models/core_user_mail.go new file mode 100644 index 00000000..5e728ea9 --- /dev/null +++ b/addons/mail/models/core_user_mail.go @@ -0,0 +1,11 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type CoreUserMail struct { + sdk.Model `sumeru:"inherit=core.user"` + + NotifyEmail sdk.Boolean `sumeru:"string=Email Notifications,default=true,column=notify_email"` +} diff --git a/addons/mail/models/mail_follower.go b/addons/mail/models/mail_follower.go new file mode 100644 index 00000000..52fd40e3 --- /dev/null +++ b/addons/mail/models/mail_follower.go @@ -0,0 +1,16 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type MailFollower struct { + sdk.Model `sumeru:"model=mail.follower"` + + ResModel sdk.String `sumeru:"required,index,column=res_model,string=Document Model"` + ResID sdk.Integer `sumeru:"required,index,column=res_id,string=Document ID"` + UserID sdk.Many2One[CoreUser] `sumeru:"required,index,string=User"` + PartnerID sdk.Many2One[CorePartner] `sumeru:"index,string=Partner"` + SubtypeIds sdk.Many2Many[MailMessageSubtype] `sumeru:"string=Subtypes,table=mail_follower_subtype_rel,left=follower_id,right=subtype_id"` + Active sdk.Boolean `sumeru:"string=Active,default=true"` +} diff --git a/addons/mail/models/mail_message_subtype.go b/addons/mail/models/mail_message_subtype.go new file mode 100644 index 00000000..f06cecc9 --- /dev/null +++ b/addons/mail/models/mail_message_subtype.go @@ -0,0 +1,16 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type MailMessageSubtype struct { + sdk.Model `sumeru:"model=mail.message.subtype"` + + Name sdk.String `sumeru:"required,unique,string=Technical Name"` + Description sdk.String `sumeru:"string=Description"` + Internal sdk.Boolean `sumeru:"string=Internal,default=false"` + Default sdk.Boolean `sumeru:"string=Default,default=false"` + Hidden sdk.Boolean `sumeru:"string=Hidden,default=false"` + Sequence sdk.Integer `sumeru:"string=Sequence,default=10"` +} diff --git a/addons/mail/models/mail_notification.go b/addons/mail/models/mail_notification.go new file mode 100644 index 00000000..d43fd79f --- /dev/null +++ b/addons/mail/models/mail_notification.go @@ -0,0 +1,18 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type MailNotification struct { + sdk.Model `sumeru:"model=mail.notification"` + + UserID sdk.Many2One[CoreUser] `sumeru:"required,index,string=User"` + MessageID sdk.Many2One[MailMessage] `sumeru:"required,index,string=Message"` + IsRead sdk.Boolean `sumeru:"string=Read,default=false"` + ReadDate sdk.DateTime `sumeru:"string=Read Date"` + RecordModel sdk.String `sumeru:"index,column=record_model,string=Record Model"` + RecordID sdk.Integer `sumeru:"index,column=record_id,string=Record ID"` + CompanyID sdk.Many2One[CoreCompany] `sumeru:"index,string=Company"` + CreateDate sdk.DateTime `sumeru:"required,string=Created"` +} diff --git a/addons/mail/models/zmodels.go b/addons/mail/models/zmodels.go index 08e37f61..d9156c79 100644 --- a/addons/mail/models/zmodels.go +++ b/addons/mail/models/zmodels.go @@ -6,11 +6,15 @@ import "sumeru/core/sdk" func init() { sdk.MustRegister("mail", + &CoreUserMail{}, &MailActivity{}, &MailActivityPlan{}, &MailActivityPlanTemplate{}, &MailActivityType{}, + &MailFollower{}, &MailMessage{}, + &MailMessageSubtype{}, + &MailNotification{}, &MailTemplate{}, ) } diff --git a/addons/mail/models/zrefs.go b/addons/mail/models/zrefs.go index bcec8b9c..9c746168 100644 --- a/addons/mail/models/zrefs.go +++ b/addons/mail/models/zrefs.go @@ -9,6 +9,9 @@ import ( // CoreCompany → core.company type CoreCompany = basemodels.CoreCompany +// CorePartner → core.partner +type CorePartner = basemodels.CorePartner + // CoreUser → core.user type CoreUser = basemodels.CoreUser diff --git a/addons/mail/security/sys.access.csv b/addons/mail/security/sys.access.csv index b3a7d2df..18727f79 100644 --- a/addons/mail/security/sys.access.csv +++ b/addons/mail/security/sys.access.csv @@ -5,3 +5,6 @@ access_mail_activity_type_user,access_mail_activity_type_user,mail.activity.type access_mail_activity_plan_user,access_mail_activity_plan_user,mail.activity.plan,base.group_system,1,1,1,1 access_mail_activity_plan_template_user,access_mail_activity_plan_template_user,mail.activity.plan.template,base.group_system,1,1,1,1 access_mail_template_user,access_mail_template_user,mail.template,base.group_system,1,1,1,1 +access_mail_follower_user,access_mail_follower_user,mail.follower,base.group_user,1,1,1,1 +access_mail_subtype_user,access_mail_subtype_user,mail.message.subtype,base.group_user,1,0,0,0 +access_mail_notification_user,access_mail_notification_user,mail.notification,base.group_user,1,1,1,0 diff --git a/core/engine/assets/css/sumeru-login.css b/core/engine/assets/css/sumeru-login.css index 6701e890..f03fffc9 100644 --- a/core/engine/assets/css/sumeru-login.css +++ b/core/engine/assets/css/sumeru-login.css @@ -3,10 +3,94 @@ .sum-login-page { background-color: var(--sum-bg); min-height: 100vh; + font-family: var(--sum-font-sans); + margin: 0; +} + +.sum-login-shell { + min-height: 100vh; + display: flex; + flex-direction: row; + align-items: stretch; +} + +.sum-login-brand { + flex: 1 1 42%; + background: linear-gradient(145deg, var(--sum-header) 0%, var(--sum-header-dark) 55%, #1a2332 100%); + color: var(--sum-surface); display: flex; align-items: center; justify-content: center; - font-family: var(--sum-font-sans); + padding: 3rem 2.5rem; +} + +.sum-login-brand-inner { + max-width: 420px; +} + +.sum-login-brand-logo { + margin: 0 0 1.75rem; + width: 72px; + height: 72px; +} + +.sum-login-brand-kicker { + margin: 0 0 0.5rem; + font-size: var(--sum-font-size-sm); + font-weight: var(--sum-font-weight-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.85; +} + +.sum-login-brand-title { + margin: 0 0 1rem; + font-size: 1.75rem; + font-weight: var(--sum-font-weight-bold); + line-height: 1.2; + letter-spacing: -0.02em; +} + +.sum-login-brand-copy { + margin: 0 0 1.5rem; + font-size: var(--sum-font-size-md); + line-height: 1.55; + opacity: 0.92; +} + +.sum-login-trust-list { + margin: 0; + padding: 0; + list-style: none; + font-size: var(--sum-font-size-sm); + line-height: 1.6; + opacity: 0.9; +} + +.sum-login-trust-list li { + position: relative; + padding-left: 1.25rem; + margin-bottom: 0.35rem; +} + +.sum-login-trust-list li::before { + content: ""; + position: absolute; + left: 0; + top: 0.55em; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--sum-surface); + opacity: 0.75; +} + +.sum-login-panel { + flex: 1 1 58%; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1.5rem; } .sum-login-card { @@ -19,6 +103,137 @@ border: 1px solid var(--sum-line); } +.sum-login-card--panel { + max-width: 440px; +} + +.sum-login-sso-list { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin-bottom: 0.5rem; +} + +.sum-login-sso { + display: block; + text-align: center; + padding: 0.75rem 1rem; + border-radius: var(--sum-radius-sm); + border: 1px solid var(--sum-line-strong); + background: var(--sum-surface); + color: var(--sum-header); + font-weight: var(--sum-font-weight-semibold); + font-size: var(--sum-font-size-md); + text-decoration: none; + transition: background var(--sum-dur-slow) var(--sum-ease), border-color var(--sum-dur-slow) var(--sum-ease); +} + +.sum-login-sso:hover { + background: var(--sum-gray-50); + border-color: var(--sum-header); +} + +.sum-login-divider { + display: flex; + align-items: center; + gap: 1rem; + margin: 1.5rem 0; + color: var(--sum-muted); + font-size: var(--sum-font-size-sm); + text-transform: uppercase; + letter-spacing: var(--sum-letter-spacing-ui); +} + +.sum-login-divider::before, +.sum-login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--sum-line); +} + +.sum-login-divider span { + flex-shrink: 0; +} + +.sum-login-info { + background: var(--sum-gray-50); + border: 1px solid var(--sum-line); + color: var(--sum-muted-ink); + padding: 0.75rem 1rem; + border-radius: var(--sum-radius-sm); + font-size: var(--sum-font-size-sm); + margin-bottom: 1.25rem; + line-height: 1.45; +} + +.sum-login-muted { + color: var(--sum-muted); + font-size: var(--sum-font-size-md); + text-align: center; + line-height: 1.5; +} + +.sum-login-footer { + margin-top: 2rem; + padding-top: 1.25rem; + border-top: 1px solid var(--sum-line); + font-size: var(--sum-font-size-sm); + color: var(--sum-muted); + text-align: center; +} + +.sum-login-footer-sep { + margin: 0 0.35rem; +} + +.sum-login-trust-device { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: var(--sum-font-size-sm); + color: var(--sum-muted-ink); + margin: 0.5rem 0 0; + cursor: pointer; +} + +.sum-login-totp-input { + font-size: 1.35rem !important; + letter-spacing: 0.35em; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.sum-login-back { + margin: 1.25rem 0 0; + text-align: center; + font-size: var(--sum-font-size-sm); +} + +.sum-login-back a { + color: var(--sum-header); + font-weight: var(--sum-font-weight-medium); +} + +@media (max-width: 900px) { + .sum-login-shell { + flex-direction: column; + } + + .sum-login-brand { + flex: none; + padding: 2rem 1.5rem; + } + + .sum-login-brand-title { + font-size: 1.35rem; + } + + .sum-login-trust-list { + display: none; + } +} + .sum-login-card .sum-login-logo { display: block; margin: 0 auto 2rem; @@ -244,6 +459,26 @@ max-width: 520px; } +.sum-settings-account-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--sum-line); +} + +.sum-settings-section-title { + margin: 0 0 0.75rem; + font-size: var(--sum-font-size-md); + font-weight: var(--sum-font-weight-bold); + color: var(--sum-header); +} + +.sum-settings-totp-qr { + display: block; + margin: 0 0 1.25rem; + border: 1px solid var(--sum-line); + border-radius: var(--sum-radius-sm); +} + .sum-setup-progress { margin: 0 0 0.35rem; font-size: var(--sum-font-size-sm); diff --git a/core/engine/assets/css/sumeru-shell.css b/core/engine/assets/css/sumeru-shell.css index b0bc7319..9f576991 100644 --- a/core/engine/assets/css/sumeru-shell.css +++ b/core/engine/assets/css/sumeru-shell.css @@ -149,6 +149,65 @@ flex-shrink: 0; } +.sum-notification-bell { + position: relative; +} + +.sum-notification-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 1rem; + padding: 0 0.25rem; + font-size: 0.65rem; + font-weight: 700; + line-height: 1rem; + text-align: center; + border-radius: 999px; + background: var(--sum-danger, #dc2626); + color: #fff; +} + +.sum-notification-panel { + position: absolute; + right: 0.75rem; + top: 3rem; + z-index: 200; + width: min(22rem, 92vw); + max-height: 20rem; + overflow: auto; + background: var(--sum-surface, #fff); + border: 1px solid var(--sum-border, #e5e5e5); + border-radius: 8px; + box-shadow: 0 8px 24px rgb(0 0 0 / 12%); + padding: 0.35rem; +} + +.sum-notification-row { + display: block; + width: 100%; + text-align: left; + padding: 0.5rem 0.65rem; + border: none; + background: transparent; + cursor: pointer; + font: inherit; +} + +.sum-notification-row--unread { + font-weight: 600; +} + +.sum-notification-mark-all { + width: 100%; + margin-top: 0.35rem; + padding: 0.35rem; + border: none; + background: var(--sum-muted-bg, #f5f5f5); + cursor: pointer; + font: inherit; +} + .sum-brand-lockup { display: flex; align-items: center; diff --git a/core/engine/render/render_types.go b/core/engine/render/render_types.go index 23fa53c2..9f88467c 100644 --- a/core/engine/render/render_types.go +++ b/core/engine/render/render_types.go @@ -93,7 +93,7 @@ type PageData struct { // BreadcrumbTrail: when non-empty, base.html renders linked crumbs; otherwise legacy ModuleName/ViewBreadcrumb. BreadcrumbItems []BreadcrumbItem - // SuppressActivityDock forces the right activity dock off (e.g. Home dashboard) regardless of mail settings. + // SuppressActivityDock forces the right activity dock off (e.g. settings hub) regardless of mail settings. SuppressActivityDock bool // SuppressSidebar omits the left sidebar entirely (e.g. Home app hub). SuppressSidebar bool diff --git a/core/engine/templates/login.html b/core/engine/templates/login.html index 079a7ca0..fb49d7be 100644 --- a/core/engine/templates/login.html +++ b/core/engine/templates/login.html @@ -3,7 +3,7 @@ - Sign in — Sumeru + Sign in — {{.AppName}} {{range .Stylesheets}} {{end}} @@ -12,36 +12,78 @@ -