milestone 18 complete
This commit is contained in:
parent
a3ef408de6
commit
0c1f39d891
11
.env.example
11
.env.example
@ -20,3 +20,14 @@ PAYMENTS_CONFIG_FILE_HOST=./deploy/payments.example.json
|
||||
|
||||
# Enable only after Google delivery and FAQ test-mode acceptance.
|
||||
AUTO_REPLY_ENABLE_LIVE=false
|
||||
|
||||
# Keep enforcement off until every user has enrolled and the security review passes.
|
||||
IDENTITY_REQUIRE_MFA=false
|
||||
# Authenticated STARTTLS SMTP. Keep credentials private and enable only after synthetic delivery acceptance.
|
||||
SMTP_ENABLED=false
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_ADDRESS=
|
||||
SMTP_FROM_NAME=GuestOps
|
||||
|
||||
@ -43,12 +43,13 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i
|
||||
| 16 | Identity, preferences, and privacy | B/C | Implemented / acceptance required | Login throttling trusts the client address only after one-hop processing by the configured proxy. A release-bound review now covers owner-controlled preferences, account/session controls, data inventory, retention/deletion/legal-hold ownership, provider decisions, audit evidence and known identity limitations. Complete the legal/operational decisions and independently approve the record. |
|
||||
| 17 | Inbox usability and desktop parity | B | Implemented / acceptance required | The inbox uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during route/history navigation, reload, conversation selection, filtering and search. Operational timestamps use the saved hotel timezone, and a release-bound desktop-parity acceptance record is implemented. The implementation and preview HTTP suite pass; run the supervised exercise against the approved release and retain independent approval. |
|
||||
| 18 | Pilot, capacity, and release approval | B/C | In progress | The `0.2.0` Gate B candidate has bounded capacity, five-business-day pilot, incident and final-decision record validators with agreed targets. Push and retain CI evidence, complete Gate A and Gate B prerequisites, run the probe and supervised exercises, resolve or contain findings, and retain separate hotel-owner and technical approval. Gate C remains dependent on milestones 13 and 14. |
|
||||
| 19 | Account security and self-service | Post-B | Implemented / acceptance required | Implemented on `milestone/19-account-security`: TOTP MFA and recovery codes, protected SMTP outbox and non-enumerating recovery, fixed granular roles with legacy `Staff` compatibility, per-user timezone/inbox preferences, aggregate monitoring, administrator Owner-MFA recovery, automated security tests, and a restricted acceptance validator. Keep SMTP and MFA enforcement disabled until synthetic delivery tests, full role/tenant checks, recovery and restart exercises, and independent security review pass. Merge and version as `0.3.0` only after `0.2.0` Gate B approval and tagging. |
|
||||
|
||||
## Delivery sequence
|
||||
|
||||
The current critical path is:
|
||||
|
||||
`9 → 10 → 11 → 12 → 15 → 18`
|
||||
`9 → 10 → 11 → 12 → 15 → 18 → 19`
|
||||
|
||||
Milestones 13 (Guestline/Rezlynx) and 14 (payments) can progress as parallel provider tracks. They do not need to delay a Google-only supervised pilot, but both remain independently gated before Gate C.
|
||||
|
||||
@ -63,6 +64,7 @@ Milestones 13 (Guestline/Rezlynx) and 14 (payments) can progress as parallel pro
|
||||
- [ ] Obtain the Guestline/Rezlynx interface contract and sandbox access.
|
||||
- [ ] Agree the payment-provider acceptance and reconciliation plan.
|
||||
- [ ] Capture named owners and target dates for milestones 9–18.
|
||||
- [ ] After Gate B approval, independently review milestone 19, validate its restricted acceptance record, merge the development branch, and create the `0.3.0` release candidate.
|
||||
|
||||
## Tracking convention
|
||||
|
||||
|
||||
@ -8,6 +8,14 @@ x-app-env: &app-env
|
||||
Google__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
|
||||
AutoReply__EnableLive: ${AUTO_REPLY_ENABLE_LIVE:-false}
|
||||
Google__EnableSending: ${GOOGLE_ENABLE_SENDING:-false}
|
||||
Identity__RequireMfa: ${IDENTITY_REQUIRE_MFA:-false}
|
||||
Mail__Enabled: ${SMTP_ENABLED:-false}
|
||||
Mail__Host: ${SMTP_HOST:-}
|
||||
Mail__Port: ${SMTP_PORT:-587}
|
||||
Mail__Username: ${SMTP_USERNAME:-}
|
||||
Mail__Password: ${SMTP_PASSWORD:-}
|
||||
Mail__FromAddress: ${SMTP_FROM_ADDRESS:-}
|
||||
Mail__FromName: ${SMTP_FROM_NAME:-GuestOps}
|
||||
Logging__LogLevel__Default: Warning
|
||||
Logging__LogLevel__Microsoft.AspNetCore.Hosting.Diagnostics: Warning
|
||||
x-logging: &logging
|
||||
|
||||
32
deploy/account-security-acceptance.example.json
Normal file
32
deploy/account-security-acceptance.example.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"system": "guestops-account-security",
|
||||
"milestone": 19,
|
||||
"releaseCommit": "0000000000000000000000000000000000000000",
|
||||
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"environment": "https://sandbox-guestops.example.invalid",
|
||||
"operator": "REPLACE OPERATOR",
|
||||
"reviewedBy": "REPLACE REVIEWER",
|
||||
"securityReviewedBy": "REPLACE SECURITY REVIEWER",
|
||||
"startedAt": "2026-10-05T09:00:00Z",
|
||||
"endedAt": "2026-10-05T12:00:00Z",
|
||||
"reviewedAt": "2026-10-05T13:00:00Z",
|
||||
"securityReviewedAt": "2026-10-05T14:00:00Z",
|
||||
"securityReviewEvidence": [],
|
||||
"productionControls": {"smtpEnabled": false, "requireMfa": false, "enabledAfterSecurityReview": false},
|
||||
"scenarios": [
|
||||
{"id":"administrator-recovery","status":"not-run","evidence":[]},
|
||||
{"id":"audit-review","status":"not-run","evidence":[]},
|
||||
{"id":"authenticator-enrollment","status":"not-run","evidence":[]},
|
||||
{"id":"legacy-staff","status":"not-run","evidence":[]},
|
||||
{"id":"monitoring","status":"not-run","evidence":[]},
|
||||
{"id":"preferences","status":"not-run","evidence":[]},
|
||||
{"id":"recovery-codes","status":"not-run","evidence":[]},
|
||||
{"id":"restart-recovery","status":"not-run","evidence":[]},
|
||||
{"id":"role-boundaries","status":"not-run","evidence":[]},
|
||||
{"id":"secret-free-interfaces","status":"not-run","evidence":[]},
|
||||
{"id":"smtp-delivery-revocation","status":"not-run","evidence":[]},
|
||||
{"id":"totp-window-replay","status":"not-run","evidence":[]}
|
||||
],
|
||||
"findings": []
|
||||
}
|
||||
66
deploy/account_security_acceptance.py
Normal file
66
deploy/account_security_acceptance.py
Normal file
@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate restricted milestone 19 account-security acceptance evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SCENARIOS={
|
||||
"authenticator-enrollment","totp-window-replay","recovery-codes","administrator-recovery",
|
||||
"smtp-delivery-revocation","role-boundaries","legacy-staff","preferences","restart-recovery",
|
||||
"monitoring","audit-review","secret-free-interfaces",
|
||||
}
|
||||
|
||||
def require(value:bool,message:str)->None:
|
||||
if not value: raise ValueError(message)
|
||||
|
||||
def timestamp(value:object,field:str)->dt.datetime:
|
||||
require(isinstance(value,str) and value.endswith("Z"),f"{field} must be a UTC timestamp ending in Z.")
|
||||
try:return dt.datetime.fromisoformat(value.removesuffix("Z")+"+00:00")
|
||||
except ValueError as error:raise ValueError(f"{field} is not a valid timestamp.") from error
|
||||
|
||||
def person(value:object,field:str)->str:
|
||||
result=str(value or "").strip();require(2<=len(result)<=120 and "@" not in result,f"{field} requires a name without an email address.");return result
|
||||
|
||||
def refs(value:object,field:str)->None:
|
||||
require(isinstance(value,list) and 1<=len(value)<=12 and all(isinstance(item,str) and 3<=len(item)<=200 and "@" not in item and "http" not in item.casefold() for item in value),f"{field} requires safe opaque evidence references.")
|
||||
|
||||
def validate(record:object)->None:
|
||||
require(isinstance(record,dict),"Acceptance record must be a JSON object.")
|
||||
require(record.get("schemaVersion")==1,"Unsupported account-security acceptance schema.")
|
||||
require(record.get("system")=="guestops-account-security","system must be guestops-account-security.")
|
||||
require(record.get("milestone")==19,"milestone must be 19.")
|
||||
require(re.fullmatch(r"[0-9a-f]{40}",str(record.get("releaseCommit",""))) is not None,"releaseCommit must be a full lowercase Git SHA.")
|
||||
require(re.fullmatch(r"[0-9a-f]{64}",str(record.get("releaseRecordSha256",""))) is not None,"releaseRecordSha256 must be a SHA-256 digest.")
|
||||
origin=urlparse(str(record.get("environment","")));require(origin.scheme=="https" and origin.hostname and origin.path in ("","/") and not origin.query and not origin.fragment and origin.username is None,"environment must be an HTTPS origin without credentials, path, query or fragment.")
|
||||
operator=person(record.get("operator"),"operator");reviewer=person(record.get("reviewedBy"),"reviewedBy");security=person(record.get("securityReviewedBy"),"securityReviewedBy")
|
||||
require(len({operator.casefold(),reviewer.casefold(),security.casefold()})==3,"operator, reviewer, and security reviewer must be different people.")
|
||||
started=timestamp(record.get("startedAt"),"startedAt");ended=timestamp(record.get("endedAt"),"endedAt");reviewed=timestamp(record.get("reviewedAt"),"reviewedAt");security_at=timestamp(record.get("securityReviewedAt"),"securityReviewedAt")
|
||||
require(started<=ended<=reviewed and ended<=security_at,"Acceptance timestamps are out of order.")
|
||||
refs(record.get("securityReviewEvidence"),"securityReviewEvidence")
|
||||
controls=record.get("productionControls");require(isinstance(controls,dict),"productionControls is required.")
|
||||
require(set(controls)=={"smtpEnabled","requireMfa","enabledAfterSecurityReview"},"productionControls has unexpected fields.")
|
||||
require(all(isinstance(controls[key],bool) for key in controls),"productionControls values must be booleans.")
|
||||
require(controls["enabledAfterSecurityReview"],"Production SMTP and MFA enforcement must be enabled only after independent security review.")
|
||||
scenarios=record.get("scenarios");require(isinstance(scenarios,list),"scenarios must be a list.")
|
||||
ids=[item.get("id") for item in scenarios if isinstance(item,dict)];require(len(ids)==len(scenarios)==len(set(ids)) and set(ids)==SCENARIOS,"Acceptance record requires the exact milestone 19 scenario set.")
|
||||
for item in scenarios:
|
||||
require(item.get("status")=="pass",f"Scenario {item['id']} has not passed.");refs(item.get("evidence"),f"Scenario {item['id']}")
|
||||
findings=record.get("findings");require(isinstance(findings,list),"findings must be a list.")
|
||||
for finding in findings:
|
||||
require(isinstance(finding,dict) and finding.get("status") in ("resolved","accepted"),"Every finding must be resolved or explicitly accepted.")
|
||||
require(3<=len(str(finding.get("id","")))<=80,"Every finding requires an opaque ID.")
|
||||
person(finding.get("owner"),f"Finding {finding.get('id')} owner");refs(finding.get("evidence"),f"Finding {finding.get('id')}")
|
||||
|
||||
def main()->None:
|
||||
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument("record",type=Path);args=parser.parse_args();validate(json.loads(args.record.read_text(encoding="utf-8")))
|
||||
print("Milestone 19 account-security acceptance record is structurally complete. This validates the record, not its restricted evidence.")
|
||||
|
||||
if __name__=="__main__":
|
||||
try:main()
|
||||
except (OSError,ValueError,json.JSONDecodeError) as error:
|
||||
print(f"Account-security acceptance record rejected: {error}",file=__import__("sys").stderr);raise SystemExit(1)
|
||||
87
docs/account-security.md
Normal file
87
docs/account-security.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Account security and self-service
|
||||
|
||||
Milestone 19 is implemented on the `milestone/19-account-security` development branch for the planned `0.3.0` release. It must not be merged into `main` or enabled in production until the `0.2.0` Gate B candidate is approved and tagged.
|
||||
|
||||
## Roles and authorization
|
||||
|
||||
GuestOps uses fixed server-enforced roles. Hiding a control in the browser is not an authorization boundary.
|
||||
|
||||
| Role | Access |
|
||||
| --- | --- |
|
||||
| Owner | All hotel, integration, provider approval, automation, privacy, team, and security operations. |
|
||||
| Manager | Inbox and reviewed Gmail sending; knowledge and FAQ test-mode management; hotel display settings; activity and health; management of Agent and Auditor accounts. |
|
||||
| Agent | Inbox, drafts, status changes, reviewed Gmail sending, PMS/payment lookups, and preparation of proposals. |
|
||||
| Auditor | Read-only activity and aggregate workspace health. No inbox bodies, guest workflow, team, or provider controls. |
|
||||
|
||||
Existing `Staff` records are normalized to Agent in sessions and permission checks. New invitations write `Agent`, `Manager`, or `Auditor`. Managers can manage only Agent and Auditor accounts. Only Owners can manage Managers, reset another user's MFA, enable integrations or writes, approve PMS/payment actions, enable live FAQ sending, or change security controls.
|
||||
|
||||
Role, password, disable/restore, and MFA changes rotate the security stamp and invalidate affected sessions. Tenant scope is always derived from the authenticated session; client-provided hotel identifiers are not authorization inputs.
|
||||
|
||||
## TOTP MFA
|
||||
|
||||
MFA uses six-digit, 30-second TOTP with SHA-1 compatibility, a one-step clock window, and atomic last-step replay prevention. Enrollment creates ten single-use recovery codes. The data-protection key ring protects TOTP secrets; only SHA-256 recovery-code hashes are stored. Enrollment and regeneration return recovery codes once.
|
||||
|
||||
When `Identity__RequireMfa=false`, current password login remains available. When it is `true`, successful password verification creates only a five-minute, HttpOnly, SameSite=Strict protected challenge cookie. A normal eight-hour session is created only after authenticator or recovery-code verification. Existing sessions without `mfa=true` are rejected on their next request. Unenrolled users are routed through enrollment after password verification.
|
||||
|
||||
Do not enable enforcement until every user has enrolled, recovery-code storage has been verified, and an independent security review has passed. Password recovery preserves MFA.
|
||||
|
||||
An Owner may reset MFA for a Manager, Agent, or Auditor after identity verification. Owner MFA reset is deliberately excluded from web controls. A server administrator performs the audited recovery:
|
||||
|
||||
```sh
|
||||
read -r -p 'Verified owner email: ' RECOVERY_EMAIL
|
||||
export RECOVERY_EMAIL
|
||||
docker compose run --rm --no-deps -e RECOVERY_EMAIL api --reset-owner-mfa
|
||||
unset RECOVERY_EMAIL
|
||||
```
|
||||
|
||||
This clears the Owner's enrollment, rotates the security stamp, ends existing sessions, and writes an audit event. It never prints a secret or recovery code. The administrator must verify identity using the approved procedure before running it.
|
||||
|
||||
## Transactional email and self-service recovery
|
||||
|
||||
`POST /api/auth/recovery` always returns the same accepted response. Known and unknown addresses receive the same response. Requests are limited independently by client IP and a SHA-256 partition of the normalized address.
|
||||
|
||||
Production invitation and reset APIs return only `userId`, `deliveryState`, and `expiresAt`. Development preview may return a direct link for interface testing. Issuing another link invalidates the previous account token.
|
||||
|
||||
The API encrypts recipient, subject, body, and token link into an `AccountMail` outbox record. The worker atomically claims each record with a lease and sends it with a stable Message-ID. Clearly pre-submission failures receive at most five bounded retries. Any exception after SMTP submission begins is treated as ambiguous and moved to `NeedsReview`; it is never automatically resent. Expired records are not submitted. Workspace health exposes aggregate counts only.
|
||||
|
||||
SMTP configuration is private environment state:
|
||||
|
||||
```text
|
||||
SMTP_ENABLED=false
|
||||
SMTP_HOST=smtp.example.invalid
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=...
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_FROM_ADDRESS=guestops@example.invalid
|
||||
SMTP_FROM_NAME=GuestOps
|
||||
```
|
||||
|
||||
The transport requires authenticated STARTTLS and normal platform certificate validation. There is no insecure-certificate option. Do not put credentials in JSON acceptance records, API responses, screenshots, logs, or source control.
|
||||
|
||||
Mandatory security notices are queued for password, MFA, role, disable/restore, and recovery events. They have no preference switch.
|
||||
|
||||
## User preferences
|
||||
|
||||
`GET/PUT /api/me/preferences` stores `displayTimeZone` and `defaultInboxFilter` with optimistic concurrency. Timezones are validated by server timezone data. Supported filters are `All`, `NeedsAttention`, `DraftReady`, and `Completed`. An empty display timezone uses the hotel's timezone. The session response includes effective permissions, MFA state, stored preferences, and the effective timezone.
|
||||
|
||||
## Safe rollout
|
||||
|
||||
1. Deploy with `SMTP_ENABLED=false` and `IDENTITY_REQUIRE_MFA=false`.
|
||||
2. Configure SMTP through private environment values and send only to synthetic accounts.
|
||||
3. Verify success, expiration, retry, restart, duplicate-claim, revocation, and ambiguous-outcome evidence; then enable SMTP.
|
||||
4. Enroll every user and verify their recovery-code storage procedure.
|
||||
5. Complete independent security review and record its evidence.
|
||||
6. Set `IDENTITY_REQUIRE_MFA=true`. Confirm password-only sessions are rejected and unenrolled users enter enrollment.
|
||||
7. Run the full role matrix, tenant-isolation, preferences, monitoring, audit, backup/restore, and restart checks.
|
||||
|
||||
SMTP or MFA failure must never weaken authorization or create a password-only bypass. A lost Owner authenticator uses only the server command above.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Copy `deploy/account-security-acceptance.example.json` to the restricted evidence store, replace all placeholders, and keep guest data, addresses, tokens, secrets, raw mail, and screenshots outside Git. Validate the completed record with:
|
||||
|
||||
```sh
|
||||
python3 deploy/account_security_acceptance.py /restricted/path/account-security-acceptance.json
|
||||
```
|
||||
|
||||
Retain the record, validator output, checksum, independent security approval, and evidence references against the same full release commit and release-record SHA-256. The validator checks structure and approval separation; it does not inspect or prove the underlying evidence.
|
||||
@ -1,5 +1,7 @@
|
||||
# Team access and hotel setup
|
||||
|
||||
> The post-pilot MFA, granular-role, SMTP-outbox, self-service recovery, and per-user preference design is documented in [account-security.md](account-security.md). This file describes the `0.2.0` Gate B account behavior retained on `main` until that release is approved.
|
||||
|
||||
Owners manage colleagues in **Your team**. The **Hotel setup** page shows progress derived from the hotel's saved MongoDB settings, approved answers, mailbox synchronization and active staff accounts. It does not enable any external action. Preview accounts and progress are temporary.
|
||||
|
||||
## Invite and recover staff
|
||||
|
||||
73
src/GuestOps.Api/AccountMail.cs
Normal file
73
src/GuestOps.Api/AccountMail.cs
Normal file
@ -0,0 +1,73 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace GuestOps.Web;
|
||||
|
||||
public sealed record AccountMailPayload(string Recipient,string Subject,string Body,string Link);
|
||||
public sealed class MailPreSubmissionException(string message):Exception(message);
|
||||
public interface IAccountMailTransport { Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken); }
|
||||
|
||||
public sealed class SmtpAccountMailTransport(IConfiguration config):IAccountMailTransport
|
||||
{
|
||||
public async Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken)
|
||||
{
|
||||
if(!config.GetValue<bool>("Mail:Enabled"))throw new MailPreSubmissionException("Disabled");
|
||||
var host=config["Mail:Host"];var username=config["Mail:Username"];var password=config["Mail:Password"];
|
||||
var from=config["Mail:FromAddress"];var fromName=config["Mail:FromName"]??"GuestOps";
|
||||
if(string.IsNullOrWhiteSpace(host)||string.IsNullOrWhiteSpace(username)||string.IsNullOrWhiteSpace(password)||!MailAddress.TryCreate(from,out var fromAddress))throw new MailPreSubmissionException("Configuration");
|
||||
using var message=new MailMessage{From=new MailAddress(fromAddress.Address,fromName),Subject=payload.Subject,Body=payload.Body,IsBodyHtml=false};
|
||||
message.To.Add(payload.Recipient);message.Headers.Add("Message-ID",messageId);
|
||||
using var smtp=new SmtpClient(host,config.GetValue("Mail:Port",587)){EnableSsl=true,UseDefaultCredentials=false,Credentials=new NetworkCredential(username,password),DeliveryMethod=SmtpDeliveryMethod.Network};
|
||||
await smtp.SendMailAsync(message,cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AccountMailService(IStore store,IDataProtectionProvider protection,IConfiguration config)
|
||||
{
|
||||
readonly IDataProtector payloads=protection.CreateProtector("GuestOps.AccountMail.v1");
|
||||
public bool Preview=>config.GetValue<bool>("Preview");
|
||||
public async Task<AccountMail> Queue(StaffUser user,string purpose,string subject,string body,string link,DateTime expiresAt)
|
||||
{
|
||||
var id=Guid.NewGuid().ToString("N");var payload=new AccountMailPayload(user.Email,subject,body+"\n\n"+link,link);
|
||||
var mail=new AccountMail{Id=id,HotelId=user.HotelId,UserId=user.Id,Purpose=purpose,ProtectedPayload=payloads.Protect(JsonSerializer.Serialize(payload)),MessageId=$"<{id}@account.guestops.invalid>",ExpiresAt=expiresAt};
|
||||
await store.Insert(mail);return mail;
|
||||
}
|
||||
public Task Notify(StaffUser user,string purpose,string detail)
|
||||
=>Queue(user,purpose,"GuestOps security notification",detail,"",DateTime.UtcNow.AddDays(7));
|
||||
public AccountMailPayload Unprotect(AccountMail mail)=>JsonSerializer.Deserialize<AccountMailPayload>(payloads.Unprotect(mail.ProtectedPayload))??throw new InvalidOperationException("Invalid protected mail payload.");
|
||||
}
|
||||
|
||||
public sealed class AccountMailProcessor(IStore store,AccountMailService mail,IAccountMailTransport transport,ILogger<AccountMailProcessor> log)
|
||||
{
|
||||
readonly string owner=Guid.NewGuid().ToString("N");
|
||||
public async Task Process(AccountMail candidate,CancellationToken cancellationToken)
|
||||
{
|
||||
var item=await store.ClaimAccountMail(candidate.Id,owner);if(item==null)return;
|
||||
var version=item.Version;
|
||||
if(item.ExpiresAt<=DateTime.UtcNow){item.State="Expired";item.ErrorCategory="Expired";item.CompletedAt=DateTime.UtcNow;item.LeaseOwner="";item.LeaseUntil=null;item.Version++;await store.Replace(item.HotelId,item.Id,version,item);return;}
|
||||
try
|
||||
{
|
||||
var payload=mail.Unprotect(item);await transport.Send(payload,item.MessageId,cancellationToken);
|
||||
item.State="Sent";item.ErrorCategory="";item.CompletedAt=DateTime.UtcNow;
|
||||
}
|
||||
catch(MailPreSubmissionException)
|
||||
{
|
||||
item.AttemptCount++;item.ErrorCategory="PreSubmission";
|
||||
if(item.AttemptCount>=5){item.State="Failed";item.CompletedAt=DateTime.UtcNow;}
|
||||
else{item.State="Pending";item.NextAttemptAt=DateTime.UtcNow.AddMinutes(Math.Min(30,1<<item.AttemptCount));}
|
||||
}
|
||||
catch(OperationCanceledException) when(cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
item.State="NeedsReview";item.ErrorCategory="Ambiguous";item.CompletedAt=DateTime.UtcNow;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
item.State="NeedsReview";item.ErrorCategory="Ambiguous";item.CompletedAt=DateTime.UtcNow;
|
||||
log.LogWarning("Account mail {MessageId} needs review after an ambiguous SMTP outcome ({Type})",item.MessageId,ex.GetType().Name);
|
||||
}
|
||||
item.LeaseOwner="";item.LeaseUntil=null;item.Version++;await store.Replace(item.HotelId,item.Id,version,item);
|
||||
}
|
||||
}
|
||||
@ -7,17 +7,17 @@ public static class AutoReplyEndpoints
|
||||
{
|
||||
public static void Map(RouteGroupBuilder api,bool preview)
|
||||
{
|
||||
var group=api.MapGroup("/auto-replies").RequireRateLimiting("pms");
|
||||
var group=api.MapGroup("/auto-replies").RequireRateLimiting("pms").RequireAuthorization(Access.AutomationTest);
|
||||
group.MapGet("/status",(AutoReplyWork work)=>Results.Ok(new{liveConfigured=!preview&&work.LiveConfigured,preview,questions=FaqMatcher.Questions,dailyLimit=20}));
|
||||
group.MapGet("/rules",async(HttpContext c,IStore store)=>Results.Ok(await store.List<AutoReplyRule>(Session.Hotel(c))));
|
||||
group.MapGet("/history",async(HttpContext c,IStore store)=>Results.Ok((await store.List<Conversation>(Session.Hotel(c))).Where(m=>m.AutoReplyCheckedAt!=null).OrderByDescending(m=>m.AutoReplyCheckedAt).Select(m=>new{m.Id,m.Subject,m.From,m.AutoReplyCheckedAt,m.AutoReplyMatched,m.AutoReplyDetail,delivery=m.Delivery?.State})));
|
||||
group.MapPost("/test",async(AutoTestInput input,HttpContext c,AutoReplyWork work)=>{
|
||||
if(!Input.Text(input.Subject,0,200)||!Input.Text(input.Body,1,2000))return Results.BadRequest();return Results.Ok(await work.Test(Session.Hotel(c),input.Subject,input.Body));
|
||||
}).RequireAuthorization("Owner");
|
||||
});
|
||||
group.MapPost("/evaluate",async(AutoEvaluationInput input,HttpContext c,AutoReplyWork work)=>{
|
||||
if(input.Cases is not {Length:>=1 and <=100} cases||cases.Select(x=>x.Id).Distinct(StringComparer.Ordinal).Count()!=cases.Length||cases.Any(x=>!Input.Text(x.Id,1,80)||!Input.Text(x.Subject,0,200)||!Input.Text(x.Body,1,2000)||x.ExpectedKnowledgeId.Length>80))return Results.BadRequest();
|
||||
var results=await work.Evaluate(Session.Hotel(c),cases);return Results.Ok(new{total=results.Length,passed=results.Count(x=>x.Passed),falsePositives=results.Count(x=>!x.ExpectedMatch&&x.ActualMatch),falseNegatives=results.Count(x=>x.ExpectedMatch&&!x.ActualMatch),results});
|
||||
}).RequireAuthorization("Owner");
|
||||
});
|
||||
group.MapPut("/rules/{question:int}",async(int question,AutoRuleInput input,HttpContext c,IStore store)=>{
|
||||
if(question<0||question>=FaqMatcher.Questions.Length||input.Question!=FaqMatcher.Questions[question])return Results.BadRequest();
|
||||
var hotel=Session.Hotel(c);var key=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hotel+":"+question))).ToLowerInvariant()[..32];
|
||||
@ -28,13 +28,14 @@ public static class AutoReplyEndpoints
|
||||
if(exists){if(!await store.Replace(hotel,key,input.Version,rule))return Input.Conflict();}
|
||||
else{try{await store.Insert(rule);}catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return Input.Conflict();}}
|
||||
await Session.Audit(store,c,"Reviewed FAQ automatic reply rule: "+rule.Question);return Results.Ok(rule);
|
||||
}).RequireAuthorization("Owner");
|
||||
});
|
||||
group.MapPut("/mode",async(AutoModeInput input,HttpContext c,IStore store,AutoReplyWork work,GoogleMailbox google)=>{
|
||||
if(input.Mode is not ("Off" or "Test" or "Live"))return Results.BadRequest();
|
||||
if(input.Mode=="Live"&&!Access.Has(c.User,Access.AutomationLive))return Results.Forbid();
|
||||
var hotel=await store.Get<Hotel>(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();
|
||||
if(input.Mode=="Live"&&(preview||!work.LiveConfigured||!google.SendingConfigured||!hotel.StaffSendingEnabled||!input.AcceptanceConfirmed))return Results.BadRequest(new{error="Live mode requires administrator enablement, Google sending, hotel sending and confirmed test-mode acceptance."});
|
||||
hotel.AutoReplyMode=input.Mode;hotel.AutoReplyEpoch=Guid.NewGuid().ToString("N");hotel.AutoReplySince=DateTime.UtcNow;hotel.Version=input.Version+1;
|
||||
if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();await Session.Audit(store,c,"Set FAQ automation mode: "+input.Mode);return Results.Ok(hotel);
|
||||
}).RequireAuthorization("Owner");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
73
src/GuestOps.Api/IdentityEndpoints.cs
Normal file
73
src/GuestOps.Api/IdentityEndpoints.cs
Normal file
@ -0,0 +1,73 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace GuestOps.Web;
|
||||
|
||||
public sealed record MfaCodeInput(string Code);
|
||||
public sealed record RecoveryInput(string Email);
|
||||
|
||||
public static class IdentityEndpoints
|
||||
{
|
||||
static async Task<StaffUser?> ChallengeUser(HttpContext c,IStore store,MfaChallenges challenges)
|
||||
{
|
||||
var challenge=challenges.Read(c);if(challenge==null)return null;
|
||||
var user=await store.Get<StaffUser>(challenge.HotelId,challenge.UserId);
|
||||
return user?.Active==true&&user.SecurityStamp==challenge.SecurityStamp?user:null;
|
||||
}
|
||||
public static void Map(WebApplication app,RouteGroupBuilder api)
|
||||
{
|
||||
app.MapPost("/api/auth/recovery",async(RecoveryInput input,HttpContext c,IStore store,TeamAccounts accounts,RecoveryRateLimits limits)=>
|
||||
{
|
||||
var email=(input.Email??"").Trim().ToLowerInvariant();var ip=c.Connection.RemoteIpAddress?.ToString()??"unknown";
|
||||
if(email.Length<=254&&limits.Allow(ip,email)&&Input.Email(email))
|
||||
{
|
||||
var user=await store.FindLogin(email);
|
||||
if(user?.Active==true&&user.PasswordHash.Length>0)
|
||||
{
|
||||
try{if(Access.NormalizeRole(user.Role)==Access.Owner)await accounts.RecoverOwner(user);else await accounts.ResetStaff(user,user.Version);}catch(AccountConflict){}catch(AccountInvalid){}
|
||||
}
|
||||
}
|
||||
return Results.Accepted(value:new{accepted=true});
|
||||
});
|
||||
app.MapPost("/api/auth/mfa/enroll",async(HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa)=>
|
||||
{
|
||||
var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var enrollment=await mfa.Begin(user);return Results.Ok(new{secret=enrollment.Secret,uri=enrollment.Uri});
|
||||
}).RequireRateLimiting("accounts");
|
||||
app.MapPost("/api/auth/mfa/enroll/verify",async(MfaCodeInput input,HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa,AccountMailService mail)=>
|
||||
{
|
||||
var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var codes=await mfa.Enable(user,input.Code);if(codes==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});
|
||||
challenges.Clear(c);await Session.SignIn(c,user,true);await mail.Notify(user,"MfaEnabled","MFA was enabled for your GuestOps account.");return Results.Ok(new{recoveryCodes=codes});
|
||||
}).RequireRateLimiting("accounts");
|
||||
app.MapPost("/api/auth/mfa/verify",async(MfaCodeInput input,HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa)=>
|
||||
{
|
||||
var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var verified=await mfa.Verify(user,input.Code);if(verified==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});challenges.Clear(c);await Session.SignIn(c,verified,true);return Results.Ok();
|
||||
}).RequireRateLimiting("accounts");
|
||||
app.MapPost("/api/auth/mfa/recovery-code",async(MfaCodeInput input,HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa,AccountMailService mail)=>
|
||||
{
|
||||
var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var verified=await mfa.UseRecovery(user,input.Code);if(verified==null)return Results.BadRequest(new{error="The recovery code is invalid or has already been used."});challenges.Clear(c);await Session.SignIn(c,verified,true);await mail.Notify(verified,"MfaRecovery","A recovery code was used to sign in to your GuestOps account.");return Results.Ok(new{remaining=verified.RecoveryCodeHashes.Length});
|
||||
}).RequireRateLimiting("accounts");
|
||||
api.MapGet("/auth/mfa/status",async(HttpContext c,IStore store)=>
|
||||
{
|
||||
var user=await store.Get<StaffUser>(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);return user==null?Results.NotFound():Results.Ok(new{enabled=user.MfaEnabledAt!=null,recoveryCodesRemaining=user.RecoveryCodeHashes.Length,required=c.RequestServices.GetRequiredService<IConfiguration>().GetValue<bool>("Identity:RequireMfa")});
|
||||
});
|
||||
api.MapPost("/auth/mfa/recovery-codes",async(MfaCodeInput input,HttpContext c,IStore store,MfaService mfa,AccountMailService mail)=>
|
||||
{
|
||||
var user=await store.Get<StaffUser>(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);if(user==null)return Results.NotFound();var codes=await mfa.Regenerate(user,input.Code);if(codes==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});await c.SignOutAsync();await Session.SignIn(c,user,true);await mail.Notify(user,"RecoveryCodesRegenerated","Your GuestOps recovery codes were regenerated.");return Results.Ok(new{recoveryCodes=codes});
|
||||
});
|
||||
api.MapPost("/auth/mfa/disable",async(MfaCodeInput input,HttpContext c,IStore store,MfaService mfa,IConfiguration config,AccountMailService mail)=>
|
||||
{
|
||||
if(config.GetValue<bool>("Identity:RequireMfa"))return Results.Conflict(new{error="MFA cannot be disabled while enforcement is enabled."});var user=await store.Get<StaffUser>(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);if(user==null)return Results.NotFound();var verified=await mfa.Verify(user,input.Code);if(verified==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});var version=verified.Version;MfaService.Reset(verified);verified.Version++;if(!await store.Replace(verified.HotelId,verified.Id,version,verified))return Input.Conflict();await c.SignOutAsync();await mail.Notify(verified,"MfaDisabled","MFA was disabled for your GuestOps account.");return Results.Ok();
|
||||
});
|
||||
api.MapGet("/me/preferences",async(HttpContext c,IStore store)=>
|
||||
{
|
||||
var id=c.User.FindFirstValue(ClaimTypes.NameIdentifier)!;var user=await store.Get<StaffUser>(Session.Hotel(c),id);var hotel=await store.Get<Hotel>(Session.Hotel(c),Session.Hotel(c));return user==null?Results.NotFound():Results.Ok(ViewPreferences(user,hotel));
|
||||
});
|
||||
api.MapPut("/me/preferences",async(PreferencesInput input,HttpContext c,IStore store)=>
|
||||
{
|
||||
if(!ValidFilter(input.DefaultInboxFilter))return Results.BadRequest(new{error="Choose a supported inbox filter."});if(input.DisplayTimeZone.Length>100)return Results.BadRequest(new{error="Choose a valid timezone."});if(input.DisplayTimeZone.Length>0)try{_=TimeZoneInfo.FindSystemTimeZoneById(input.DisplayTimeZone);}catch{return Results.BadRequest(new{error="Choose a valid timezone."});}
|
||||
var id=c.User.FindFirstValue(ClaimTypes.NameIdentifier)!;var user=await store.Get<StaffUser>(Session.Hotel(c),id);if(user==null)return Results.NotFound();user.DisplayTimeZone=input.DisplayTimeZone;user.DefaultInboxFilter=input.DefaultInboxFilter;user.Version=input.Version+1;if(!await store.Replace(user.HotelId,user.Id,input.Version,user))return Input.Conflict();var hotel=await store.Get<Hotel>(user.HotelId,user.HotelId);return Results.Ok(ViewPreferences(user,hotel));
|
||||
});
|
||||
}
|
||||
public static bool ValidFilter(string value)=>value is "All" or "NeedsAttention" or "DraftReady" or "Completed";
|
||||
public static object ViewPreferences(StaffUser user,Hotel? hotel)=>new{user.DisplayTimeZone,effectiveDisplayTimeZone=user.DisplayTimeZone.Length>0?user.DisplayTimeZone:hotel?.Timezone??"UTC",user.DefaultInboxFilter,user.Version};
|
||||
}
|
||||
146
src/GuestOps.Api/IdentitySecurity.cs
Normal file
146
src/GuestOps.Api/IdentitySecurity.cs
Normal file
@ -0,0 +1,146 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace GuestOps.Web;
|
||||
|
||||
public static class Access
|
||||
{
|
||||
public const string Owner="Owner", Manager="Manager", Agent="Agent", Auditor="Auditor";
|
||||
public const string Inbox="Inbox", Send="Send", Knowledge="Knowledge", HotelSettings="HotelSettings",
|
||||
Audit="Audit", Operations="Operations", Team="Team", Security="Security", Integrations="Integrations",
|
||||
ExternalApproval="ExternalApproval", AutomationTest="AutomationTest", AutomationLive="AutomationLive",
|
||||
ProviderLookup="ProviderLookup", ProviderProposal="ProviderProposal";
|
||||
public static readonly string[] Roles=[Owner,Manager,Agent,Auditor];
|
||||
static readonly IReadOnlyDictionary<string,string[]> Grants=new Dictionary<string,string[]>
|
||||
{
|
||||
[Owner]=[Inbox,Send,Knowledge,HotelSettings,Audit,Operations,Team,Security,Integrations,ExternalApproval,AutomationTest,AutomationLive,ProviderLookup,ProviderProposal],
|
||||
[Manager]=[Inbox,Send,Knowledge,HotelSettings,Audit,Operations,Team,AutomationTest,ProviderLookup,ProviderProposal],
|
||||
[Agent]=[Inbox,Send,ProviderLookup,ProviderProposal],
|
||||
[Auditor]=[Audit,Operations]
|
||||
};
|
||||
public static string NormalizeRole(string? role)=>role=="Staff"?Agent:Roles.Contains(role,StringComparer.Ordinal)?role!:Agent;
|
||||
public static IReadOnlyList<string> Permissions(string? role)=>Grants[NormalizeRole(role)];
|
||||
public static bool Has(ClaimsPrincipal principal,string permission)=>Permissions(principal.FindFirstValue(ClaimTypes.Role)).Contains(permission,StringComparer.Ordinal);
|
||||
public static bool CanManage(string actorRole,string targetRole)
|
||||
{
|
||||
actorRole=NormalizeRole(actorRole);targetRole=NormalizeRole(targetRole);
|
||||
return actorRole==Owner?targetRole!=Owner:actorRole==Manager&&targetRole is Agent or Auditor;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Totp
|
||||
{
|
||||
const string Alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
public static byte[] NewSecret()=>RandomNumberGenerator.GetBytes(20);
|
||||
public static long Step(DateTime utcNow)=>new DateTimeOffset(utcNow.ToUniversalTime()).ToUnixTimeSeconds()/30;
|
||||
public static string Code(byte[] secret,long step)
|
||||
{
|
||||
Span<byte> counter=stackalloc byte[8];System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(counter,step);
|
||||
var hash=HMACSHA1.HashData(secret,counter);var offset=hash[^1]&15;
|
||||
var value=((hash[offset]&127)<<24)|(hash[offset+1]<<16)|(hash[offset+2]<<8)|hash[offset+3];
|
||||
return (value%1_000_000).ToString("D6",CultureInfo.InvariantCulture);
|
||||
}
|
||||
public static long? Verify(byte[] secret,string? code,DateTime utcNow,long? lastUsed)
|
||||
{
|
||||
if(code is null||code.Length!=6||!code.All(char.IsAsciiDigit))return null;
|
||||
var now=Step(utcNow);
|
||||
for(var delta=-1;delta<=1;delta++)
|
||||
{
|
||||
var candidate=now+delta;
|
||||
if(candidate<=lastUsed)continue;
|
||||
if(CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(Code(secret,candidate)),Encoding.ASCII.GetBytes(code)))return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
public static string Encode(byte[] data)
|
||||
{
|
||||
var output=new StringBuilder();var buffer=0;var bits=0;
|
||||
foreach(var b in data){buffer=(buffer<<8)|b;bits+=8;while(bits>=5){bits-=5;output.Append(Alphabet[(buffer>>bits)&31]);}}
|
||||
if(bits>0)output.Append(Alphabet[(buffer<<(5-bits))&31]);return output.ToString();
|
||||
}
|
||||
public static byte[] Decode(string value)
|
||||
{
|
||||
var bytes=new List<byte>();var buffer=0;var bits=0;
|
||||
foreach(var c in value.Trim().TrimEnd('=').ToUpperInvariant()){var index=Alphabet.IndexOf(c);if(index<0)throw new FormatException("Invalid Base32 value.");buffer=(buffer<<5)|index;bits+=5;if(bits>=8){bits-=8;bytes.Add((byte)(buffer>>bits));buffer&=(1<<bits)-1;}}
|
||||
return bytes.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MfaChallenge(string UserId,string HotelId,string SecurityStamp,long ExpiresUnix);
|
||||
public sealed class MfaChallenges(IDataProtectionProvider protection,IConfiguration config)
|
||||
{
|
||||
const string Cookie="guestops.mfa";
|
||||
readonly IDataProtector protector=protection.CreateProtector("GuestOps.MfaChallenge.v1");
|
||||
public void Write(HttpContext c,StaffUser user)
|
||||
{
|
||||
var value=protector.Protect(JsonSerializer.Serialize(new MfaChallenge(user.Id,user.HotelId,user.SecurityStamp,DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds())));
|
||||
c.Response.Cookies.Append(Cookie,value,new(){HttpOnly=true,Secure=!config.GetValue<bool>("Preview"),SameSite=SameSiteMode.Strict,MaxAge=TimeSpan.FromMinutes(5),Path="/api/auth/mfa"});
|
||||
}
|
||||
public MfaChallenge? Read(HttpContext c)
|
||||
{
|
||||
try{if(!c.Request.Cookies.TryGetValue(Cookie,out var value))return null;var result=JsonSerializer.Deserialize<MfaChallenge>(protector.Unprotect(value));return result?.ExpiresUnix>=DateTimeOffset.UtcNow.ToUnixTimeSeconds()?result:null;}catch(CryptographicException){return null;}catch(JsonException){return null;}
|
||||
}
|
||||
public void Clear(HttpContext c)=>c.Response.Cookies.Delete(Cookie,new(){Path="/api/auth/mfa"});
|
||||
}
|
||||
|
||||
public sealed class MfaService(IStore store,IDataProtectionProvider protection)
|
||||
{
|
||||
readonly IDataProtector secrets=protection.CreateProtector("GuestOps.TotpSecret.v1");
|
||||
static string RecoveryHash(StaffUser user,string code)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(user.Id+":"+NormalizeRecovery(code))));
|
||||
static string NormalizeRecovery(string code)=>code.Replace("-","").Trim().ToUpperInvariant();
|
||||
public async Task<(string Secret,string Uri)> Begin(StaffUser user)
|
||||
{
|
||||
if(user.MfaEnabledAt!=null)throw new AccountConflict("MFA is already enabled.");
|
||||
byte[] secret;
|
||||
if(user.MfaPendingSecretProtected.Length==0)
|
||||
{
|
||||
secret=Totp.NewSecret();var version=user.Version;user.MfaPendingSecretProtected=secrets.Protect(Convert.ToBase64String(secret));user.Version++;
|
||||
if(!await store.Replace(user.HotelId,user.Id,version,user))throw new AccountConflict("The account changed elsewhere. Try again.");
|
||||
}
|
||||
else secret=Convert.FromBase64String(secrets.Unprotect(user.MfaPendingSecretProtected));
|
||||
var encoded=Totp.Encode(secret);var label=Uri.EscapeDataString("GuestOps:"+user.Email);var issuer=Uri.EscapeDataString("GuestOps");
|
||||
return(encoded,$"otpauth://totp/{label}?secret={encoded}&issuer={issuer}&algorithm=SHA1&digits=6&period=30");
|
||||
}
|
||||
public async Task<string[]?> Enable(StaffUser user,string code,DateTime? now=null)
|
||||
{
|
||||
if(user.MfaEnabledAt!=null||user.MfaPendingSecretProtected.Length==0)return null;
|
||||
var secret=Convert.FromBase64String(secrets.Unprotect(user.MfaPendingSecretProtected));var step=Totp.Verify(secret,code,now??DateTime.UtcNow,user.LastTotpStep);if(step==null)return null;
|
||||
var raw=Enumerable.Range(0,10).Select(_=>$"{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}").ToArray();
|
||||
var version=user.Version;user.MfaSecretProtected=user.MfaPendingSecretProtected;user.MfaPendingSecretProtected="";user.MfaEnabledAt=DateTime.UtcNow;user.LastTotpStep=step;user.RecoveryCodeHashes=raw.Select(x=>RecoveryHash(user,x)).ToArray();user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++;
|
||||
return await store.Replace(user.HotelId,user.Id,version,user)?raw:null;
|
||||
}
|
||||
public async Task<StaffUser?> Verify(StaffUser user,string code,DateTime? now=null)
|
||||
{
|
||||
if(user.MfaEnabledAt==null||user.MfaSecretProtected.Length==0)return null;
|
||||
var secret=Convert.FromBase64String(secrets.Unprotect(user.MfaSecretProtected));var step=Totp.Verify(secret,code,now??DateTime.UtcNow,user.LastTotpStep);if(step==null)return null;
|
||||
var version=user.Version;user.LastTotpStep=step;user.Version++;return await store.Replace(user.HotelId,user.Id,version,user)?user:null;
|
||||
}
|
||||
public async Task<StaffUser?> UseRecovery(StaffUser user,string code)
|
||||
{
|
||||
var hash=RecoveryHash(user,code);var index=Array.FindIndex(user.RecoveryCodeHashes,x=>CryptographicOperations.FixedTimeEquals(Convert.FromHexString(x),Convert.FromHexString(hash)));if(index<0)return null;
|
||||
var version=user.Version;user.RecoveryCodeHashes=user.RecoveryCodeHashes.Where((_,i)=>i!=index).ToArray();user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++;
|
||||
return await store.Replace(user.HotelId,user.Id,version,user)?user:null;
|
||||
}
|
||||
public async Task<string[]?> Regenerate(StaffUser user,string code)
|
||||
{
|
||||
var verified=await Verify(user,code);if(verified==null)return null;
|
||||
var raw=Enumerable.Range(0,10).Select(_=>$"{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}").ToArray();
|
||||
var version=verified.Version;verified.RecoveryCodeHashes=raw.Select(x=>RecoveryHash(verified,x)).ToArray();verified.SecurityStamp=Guid.NewGuid().ToString("N");verified.Version++;
|
||||
return await store.Replace(verified.HotelId,verified.Id,version,verified)?raw:null;
|
||||
}
|
||||
public static void Reset(StaffUser user){user.MfaSecretProtected="";user.MfaPendingSecretProtected="";user.MfaEnabledAt=null;user.LastTotpStep=null;user.RecoveryCodeHashes=[];user.SecurityStamp=Guid.NewGuid().ToString("N");}
|
||||
}
|
||||
|
||||
public sealed class RecoveryRateLimits
|
||||
{
|
||||
readonly System.Collections.Concurrent.ConcurrentDictionary<string,(DateTime Start,int Count)> windows=new();
|
||||
bool Take(string key,int limit)
|
||||
{
|
||||
var now=DateTime.UtcNow;while(true){var old=windows.GetOrAdd(key,_=>(now,0));var next=now-old.Start>=TimeSpan.FromMinutes(15)?(now,1):(old.Start,old.Count+1);if(old.Count>=limit&&now-old.Start<TimeSpan.FromMinutes(15))return false;if(windows.TryUpdate(key,next,old))return true;}
|
||||
}
|
||||
public bool Allow(string ip,string email)=>Take("ip:"+ip,10)&&Take("address:"+Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(email))),5);
|
||||
}
|
||||
@ -41,7 +41,7 @@ public static class MailboxManagement
|
||||
if(action is not ("disconnect" or "retry"))return Results.NotFound();
|
||||
if(!await Change(store,box,input.Version,action))return Results.Conflict(new{error="The mailbox changed, needs reconnection, or is waiting for its retry time. Refresh the status."});
|
||||
await Session.Audit(store,c,action=="disconnect"?"Disconnected Google mailbox from GuestOps":"Requested a fresh mailbox import pass");return Results.Ok(View(box));
|
||||
}).RequireAuthorization("Owner").RequireRateLimiting("accounts");
|
||||
}).RequireAuthorization(Access.Integrations).RequireRateLimiting("accounts");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -33,7 +33,32 @@ public class StaffUser : TenantDocument
|
||||
public string Name { get; set; } = "";
|
||||
public string PasswordHash { get; set; } = "";
|
||||
public string Role { get; set; } = "Owner";
|
||||
public int RoleVersion { get; set; } = 1;
|
||||
public bool Active { get; set; } = true;
|
||||
public string MfaSecretProtected { get; set; } = "";
|
||||
public string MfaPendingSecretProtected { get; set; } = "";
|
||||
public DateTime? MfaEnabledAt { get; set; }
|
||||
public long? LastTotpStep { get; set; }
|
||||
public string[] RecoveryCodeHashes { get; set; } = [];
|
||||
public DateTime? EmailVerifiedAt { get; set; }
|
||||
public string DisplayTimeZone { get; set; } = "";
|
||||
public string DefaultInboxFilter { get; set; } = "All";
|
||||
}
|
||||
public class AccountMail : TenantDocument
|
||||
{
|
||||
public long Version { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string Purpose { get; set; } = "";
|
||||
public string ProtectedPayload { get; set; } = "";
|
||||
public string MessageId { get; set; } = "";
|
||||
public string State { get; set; } = "Pending";
|
||||
public int AttemptCount { get; set; }
|
||||
public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public string LeaseOwner { get; set; } = "";
|
||||
public DateTime? LeaseUntil { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string ErrorCategory { get; set; } = "";
|
||||
}
|
||||
public class KnowledgeEntry : TenantDocument
|
||||
{
|
||||
@ -108,6 +133,7 @@ public class WorkerLease
|
||||
public DateTime Until { get; set; }
|
||||
}
|
||||
public record LoginInput(string Email, string Password);
|
||||
public record PreferencesInput(string DisplayTimeZone, string DefaultInboxFilter, long Version);
|
||||
public record SettingsInput(string Name, string Timezone, string Signature, long Version);
|
||||
public record DraftInput(string Draft, long Version);
|
||||
public record StatusInput(string Status, long Version);
|
||||
|
||||
@ -22,8 +22,8 @@ public static class Operations
|
||||
});
|
||||
api.MapGet("/operations",async(HttpContext c,IStore store)=>
|
||||
{
|
||||
var id=Session.Hotel(c);var hotel=await store.Get<Hotel>(id,id);var boxes=await store.List<Mailbox>(id);var messages=await store.List<Conversation>(id);var seen=await store.WorkerLastSeen();
|
||||
return Results.Ok(new{checkedAt=DateTime.UtcNow,timeZone=hotel?.Timezone??"UTC",preview,database="Reachable",worker=new{lastSeenAt=seen,state=preview?"Preview":seen==null?"NotSeen":seen<DateTime.UtcNow.AddMinutes(-3)?"Stale":"Reporting"},mailboxes=new{total=boxes.Count,connected=boxes.Count(x=>x.Status=="Connected"),attention=boxes.Count(x=>x.Status!="Connected"||x.SyncError.Length>0)},replies=new{sampleSize=messages.Count,sampleLimit=500,pending=messages.Count(x=>x.Delivery?.State is "Pending" or "Sending"),uncertain=messages.Count(x=>x.Delivery?.State=="NeedsReview"),rejected=messages.Count(x=>x.Delivery?.State=="Rejected")}});
|
||||
}).RequireAuthorization("Owner");
|
||||
var id=Session.Hotel(c);var hotel=await store.Get<Hotel>(id,id);var boxes=await store.List<Mailbox>(id);var messages=await store.List<Conversation>(id);var accountMail=await store.List<AccountMail>(id);var seen=await store.WorkerLastSeen();
|
||||
return Results.Ok(new{checkedAt=DateTime.UtcNow,timeZone=hotel?.Timezone??"UTC",preview,database="Reachable",worker=new{lastSeenAt=seen,state=preview?"Preview":seen==null?"NotSeen":seen<DateTime.UtcNow.AddMinutes(-3)?"Stale":"Reporting"},mailboxes=new{total=boxes.Count,connected=boxes.Count(x=>x.Status=="Connected"),attention=boxes.Count(x=>x.Status!="Connected"||x.SyncError.Length>0)},replies=new{sampleSize=messages.Count,sampleLimit=500,pending=messages.Count(x=>x.Delivery?.State is "Pending" or "Sending"),uncertain=messages.Count(x=>x.Delivery?.State=="NeedsReview"),rejected=messages.Count(x=>x.Delivery?.State=="Rejected")},accountMail=new{pending=accountMail.Count(x=>x.State is "Pending" or "Sending"),needsReview=accountMail.Count(x=>x.State=="NeedsReview"),failed=accountMail.Count(x=>x.State is "Failed" or "Expired")}});
|
||||
}).RequireAuthorization(Access.Operations);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,29 +4,29 @@ public static class PaymentEndpoints
|
||||
{
|
||||
public static void Map(RouteGroupBuilder api,bool preview)
|
||||
{
|
||||
var group=api.MapGroup("/payments").RequireRateLimiting("pms");
|
||||
var group=api.MapGroup("/payments").RequireRateLimiting("pms").RequireAuthorization(Access.ProviderLookup);
|
||||
group.MapGet("/status",(HttpContext c,IConfiguration config)=>{var p=preview?null:NmiProfile.Read(config,Session.Hotel(c));return Results.Ok(new{configured=p!=null,createsConfigured=p?.CreatesEnabled==true,sandbox=p?.BaseUrl=="https://sandbox.nmi.com",preview});});
|
||||
group.MapGet("/requests",async(HttpContext c,IStore store)=>Results.Ok((await store.List<PaymentRequest>(Session.Hotel(c))).OrderByDescending(p=>p.UpdatedAt).Select(p=>p.View())));
|
||||
group.MapPost("/requests",async(PaymentInput input,HttpContext c,PaymentWork work,IStore store)=>{
|
||||
if(preview)return Results.BadRequest(new{error="Real payment creation is disabled in preview."});
|
||||
var p=await work.Propose(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input);await Session.Audit(store,c,"Prepared payment request for review");return Results.Ok(p.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.ProviderProposal);
|
||||
group.MapPost("/requests/{id}/create",async(string id,PaymentApproval input,HttpContext c,PaymentWork work,IStore store)=>{
|
||||
var p=await store.Get<PaymentRequest>(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest();
|
||||
var result=await work.Create(p,input.Version,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input.EmailAndAmountApproved,c.RequestAborted);await Session.Audit(store,c,"Payment creation result: "+result.State);return Results.Ok(result.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.ExternalApproval);
|
||||
group.MapPost("/requests/{id}/check",async(string id,VersionInput input,HttpContext c,PaymentWork work,IStore store)=>{
|
||||
var p=await store.Get<PaymentRequest>(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest();
|
||||
var result=await work.Check(p,input.Version,c.RequestAborted);await Session.Audit(store,c,"Checked payment invoice: "+result.State);return Results.Ok(result.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.ExternalApproval);
|
||||
group.MapPost("/requests/{id}/cancel",async(string id,VersionInput input,HttpContext c,PaymentWork work,IStore store)=>{
|
||||
var p=await store.Get<PaymentRequest>(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest();
|
||||
var result=await work.Cancel(p,input.Version);await Session.Audit(store,c,"Cancelled unsubmitted payment proposal");return Results.Ok(result.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.ExternalApproval);
|
||||
group.MapPut("/controls",async(PmsControlsInput input,HttpContext c,IStore store,IConfiguration config)=>{
|
||||
if(input.Enabled&&(preview||NmiProfile.Read(config,Session.Hotel(c))?.CreatesEnabled!=true))return Results.BadRequest(new{error="Administrator payment enablement and sandbox acceptance are required first."});
|
||||
var hotel=await store.Get<Hotel>(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();hotel.PaymentsEnabled=input.Enabled;hotel.Version=input.Version+1;
|
||||
if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();await Session.Audit(store,c,"Updated payment creation control");return Results.Ok(hotel);
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.Integrations);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ public static class PmsEndpoints
|
||||
{
|
||||
public static void Map(RouteGroupBuilder api,bool preview)
|
||||
{
|
||||
var group=api.MapGroup("/pms").RequireRateLimiting("pms");
|
||||
var group=api.MapGroup("/pms").RequireRateLimiting("pms").RequireAuthorization(Access.ProviderLookup);
|
||||
group.MapGet("/status",(HttpContext c,IConfiguration config)=>
|
||||
{
|
||||
var profile=preview?null:OhipProfile.Read(config,Session.Hotel(c));
|
||||
@ -23,29 +23,29 @@ public static class PmsEndpoints
|
||||
if(preview)return Results.BadRequest();
|
||||
var change=await work.Propose(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,snapshot,input);
|
||||
await Session.Audit(store,c,"Prepared a PMS change for review");return Results.Ok(change.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.ProviderProposal);
|
||||
group.MapPost("/changes/{id}/apply",async(string id,PmsApproveInput input,HttpContext c,PmsWork work,IStore store)=>
|
||||
{
|
||||
var change=await store.Get<PmsChange>(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest();
|
||||
var result=await work.Apply(change,input.Version,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input.AvailabilityAndPriceChecked,c.RequestAborted);
|
||||
await Session.Audit(store,c,"PMS change result: "+result.State);return Results.Ok(result.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.ExternalApproval);
|
||||
group.MapPost("/changes/{id}/verify",async(string id,VersionInput input,HttpContext c,PmsWork work,IStore store)=>
|
||||
{
|
||||
var change=await store.Get<PmsChange>(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest();
|
||||
var result=await work.Verify(change,input.Version,c.RequestAborted);await Session.Audit(store,c,"Verified PMS state: "+result.State);return Results.Ok(result.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.ExternalApproval);
|
||||
group.MapPost("/changes/{id}/cancel",async(string id,VersionInput input,HttpContext c,PmsWork work,IStore store)=>
|
||||
{
|
||||
var change=await store.Get<PmsChange>(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest();
|
||||
var result=await work.Cancel(change,input.Version);await Session.Audit(store,c,"Cancelled an unapplied PMS proposal");return Results.Ok(result.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.ExternalApproval);
|
||||
group.MapPut("/controls",async(PmsControlsInput input,HttpContext c,IStore store,IConfiguration config)=>
|
||||
{
|
||||
if(input.Enabled&&(preview||OhipProfile.Read(config,Session.Hotel(c))?.WritesEnabled!=true))return Results.BadRequest(new{error="Server-side PMS writes must be enabled after sandbox acceptance first."});
|
||||
var hotel=await store.Get<Hotel>(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();hotel.PmsUpdatesEnabled=input.Enabled;hotel.Version=input.Version+1;
|
||||
if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();
|
||||
await Session.Audit(store,c,"Updated PMS write controls");return Results.Ok(hotel);
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.Integrations);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,9 @@ using System.Net;
|
||||
|
||||
var bootstrap = args.Contains("--bootstrap");
|
||||
var recoverOwner = args.Contains("--recover-owner");
|
||||
var resetOwnerMfa = args.Contains("--reset-owner-mfa");
|
||||
var backupProbe=args.Contains("--backup-probe");var verifyBackupProbe=args.Contains("--verify-backup-probe");
|
||||
var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap" && arg != "--recover-owner" && arg != "--backup-probe" && arg != "--verify-backup-probe").ToArray());
|
||||
var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap" && arg != "--recover-owner" && arg != "--reset-owner-mfa" && arg != "--backup-probe" && arg != "--verify-backup-probe").ToArray());
|
||||
if (builder.Configuration["Pms:ConfigFile"] is { Length: > 0 } pmsConfigFile) builder.Configuration.AddJsonFile(pmsConfigFile,optional:false,reloadOnChange:false);
|
||||
if (builder.Configuration["Payments:ConfigFile"] is { Length: > 0 } paymentConfigFile) builder.Configuration.AddJsonFile(paymentConfigFile,optional:false,reloadOnChange:false);
|
||||
builder.Logging.ClearProviders(); builder.Logging.AddConsole();
|
||||
@ -38,6 +39,12 @@ builder.Services.AddHttpClient<NmiInvoices>(c=>c.Timeout=TimeSpan.FromSeconds(25
|
||||
builder.Services.AddTransient<PaymentWork>();
|
||||
builder.Services.AddTransient<AutoReplyWork>();
|
||||
builder.Services.AddTransient<TeamAccounts>();
|
||||
builder.Services.AddTransient<MfaService>();
|
||||
builder.Services.AddSingleton<MfaChallenges>();
|
||||
builder.Services.AddSingleton<RecoveryRateLimits>();
|
||||
builder.Services.AddTransient<AccountMailService>();
|
||||
builder.Services.AddTransient<IAccountMailTransport,SmtpAccountMailTransport>();
|
||||
builder.Services.AddTransient<AccountMailProcessor>();
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o =>
|
||||
{
|
||||
o.Cookie.Name = "guestops.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Lax;
|
||||
@ -49,10 +56,17 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||
{
|
||||
var hotel = c.Principal?.FindFirstValue("hotel"); var id = c.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var user = hotel == null || id == null ? null : await c.HttpContext.RequestServices.GetRequiredService<IStore>().Get<StaffUser>(hotel, id);
|
||||
if (user == null || !TeamAccounts.SessionValid(user,c.Principal?.FindFirstValue("security_stamp")) || user.Role != c.Principal!.FindFirstValue(ClaimTypes.Role)) c.RejectPrincipal();
|
||||
var normalized=user==null?null:Access.NormalizeRole(user.Role);var claimed=c.Principal?.FindFirstValue(ClaimTypes.Role);
|
||||
var mfaRequired=c.HttpContext.RequestServices.GetRequiredService<IConfiguration>().GetValue<bool>("Identity:RequireMfa");
|
||||
if (user == null || !TeamAccounts.SessionValid(user,c.Principal?.FindFirstValue("security_stamp")) || normalized != claimed || mfaRequired&&c.Principal?.FindFirstValue("mfa")!="true") c.RejectPrincipal();
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner")));
|
||||
builder.Services.AddAuthorization(o =>
|
||||
{
|
||||
o.AddPolicy("Owner",p=>p.RequireRole(Access.Owner));
|
||||
foreach(var permission in new[]{Access.Inbox,Access.Send,Access.Knowledge,Access.HotelSettings,Access.Audit,Access.Operations,Access.Team,Access.Security,Access.Integrations,Access.ExternalApproval,Access.AutomationTest,Access.AutomationLive,Access.ProviderLookup,Access.ProviderProposal})
|
||||
o.AddPolicy(permission,p=>p.RequireAssertion(c=>Access.Has(c.User,permission)));
|
||||
});
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(o =>
|
||||
{
|
||||
// Trust scheme and client address only from the explicitly configured host proxy.
|
||||
@ -90,6 +104,13 @@ if(recoverOwner)
|
||||
var recovery=await app.Services.GetRequiredService<TeamAccounts>().RecoverOwner(user);
|
||||
Console.WriteLine("Private single-use recovery link (expires in 30 minutes). Share only with the verified account owner:");Console.WriteLine(recovery.Link);return;
|
||||
}
|
||||
if(resetOwnerMfa)
|
||||
{
|
||||
var email=Environment.GetEnvironmentVariable("RECOVERY_EMAIL")?.Trim().ToLowerInvariant()??"";var user=await store.FindLogin(email);
|
||||
if(user==null||!user.Active||Access.NormalizeRole(user.Role)!=Access.Owner)throw new InvalidOperationException("An active owner account is required.");
|
||||
var version=user.Version;MfaService.Reset(user);user.Version++;if(!await store.Replace(user.HotelId,user.Id,version,user))throw new InvalidOperationException("The owner account changed; run the command again.");
|
||||
await store.Insert(new Activity{HotelId=user.HotelId,UserName="Server administrator",Action="Reset owner MFA after identity verification"});Console.WriteLine("Owner MFA reset and all existing sessions invalidated.");return;
|
||||
}
|
||||
if (bootstrap)
|
||||
{
|
||||
var email = Environment.GetEnvironmentVariable("BOOTSTRAP_EMAIL")?.Trim().ToLowerInvariant() ?? "";
|
||||
@ -133,11 +154,15 @@ app.Use(async (ctx, next) =>
|
||||
await next();
|
||||
});
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ready" }));
|
||||
app.MapGet("/api/session", (HttpContext c, IAntiforgery csrf) => Results.Ok(new
|
||||
app.MapGet("/api/session", async (HttpContext c, IAntiforgery csrf) =>
|
||||
{
|
||||
preview, csrfToken = csrf.GetAndStoreTokens(c).RequestToken,
|
||||
user = c.User.Identity?.IsAuthenticated == true ? new { id = c.User.FindFirstValue(ClaimTypes.NameIdentifier), name = c.User.Identity.Name, role = c.User.FindFirstValue(ClaimTypes.Role), hotelId = c.User.FindFirstValue("hotel") } : null
|
||||
}));
|
||||
object? view=null;if(c.User.Identity?.IsAuthenticated==true)
|
||||
{
|
||||
var hotelId=Session.Hotel(c);var user=await store.Get<StaffUser>(hotelId,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);var hotel=await store.Get<Hotel>(hotelId,hotelId);
|
||||
if(user!=null)view=new{id=user.Id,name=user.Name,role=Access.NormalizeRole(user.Role),hotelId,permissions=Access.Permissions(user.Role),mfaEnabled=user.MfaEnabledAt!=null,preferences=IdentityEndpoints.ViewPreferences(user,hotel)};
|
||||
}
|
||||
return Results.Ok(new{preview,csrfToken=csrf.GetAndStoreTokens(c).RequestToken,user=view});
|
||||
});
|
||||
app.MapPost("/api/auth/login", async (LoginInput input, HttpContext c, IPasswordHasher<StaffUser> hasher) =>
|
||||
{
|
||||
if (input.Email == null || input.Password == null || input.Email.Length > 254 || input.Password.Length > 256) return Results.BadRequest(new { error = "Invalid credentials." });
|
||||
@ -147,18 +172,23 @@ app.MapPost("/api/auth/login", async (LoginInput input, HttpContext c, IPassword
|
||||
var hash = string.IsNullOrEmpty(user?.PasswordHash) ? Input.DummyHash : user.PasswordHash;
|
||||
if (hasher.VerifyHashedPassword(checkUser, hash, input.Password) == PasswordVerificationResult.Failed || user?.Active != true)
|
||||
return Results.Json(new { error = "Email or password is incorrect." }, statusCode: 401);
|
||||
await Session.SignIn(c, user); return Results.Ok();
|
||||
if(builder.Configuration.GetValue<bool>("Identity:RequireMfa"))
|
||||
{
|
||||
app.Services.GetRequiredService<MfaChallenges>().Write(c,user);return Results.Json(new{mfaRequired=true,enrollmentRequired=user.MfaEnabledAt==null},statusCode:202);
|
||||
}
|
||||
await Session.SignIn(c, user,false); return Results.Ok(new{mfaRequired=false});
|
||||
}).RequireRateLimiting("login");
|
||||
app.MapPost("/api/auth/logout", async (HttpContext c, CancellationToken _) => { await c.SignOutAsync(); return Results.Ok(); }).RequireAuthorization();
|
||||
if (preview) app.MapPost("/api/preview/start", async (HttpContext c, CancellationToken _) => { var user = await Demo.Seed(store); await Session.SignIn(c, user); return Results.Ok(); }).RequireRateLimiting("login");
|
||||
var api = app.MapGroup("/api").RequireAuthorization();
|
||||
IdentityEndpoints.Map(app,api);
|
||||
PmsEndpoints.Map(api,preview);
|
||||
PaymentEndpoints.Map(api,preview);
|
||||
AutoReplyEndpoints.Map(api,preview);
|
||||
TeamEndpoints.Map(app,api,preview);
|
||||
MailboxManagement.Map(api,preview);
|
||||
Operations.Map(app,api,preview);
|
||||
api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get<Hotel>(Session.Hotel(c), Session.Hotel(c))));
|
||||
api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get<Hotel>(Session.Hotel(c), Session.Hotel(c)))).RequireAuthorization(Access.Inbox);
|
||||
api.MapPut("/hotel", async (SettingsInput input, HttpContext c) =>
|
||||
{
|
||||
if (!Input.Text(input.Name, 2, 120) || !Input.Text(input.Signature, 0, 2000)) return Results.BadRequest(new { error = "Enter a hotel name and a signature under 2,000 characters." });
|
||||
@ -167,10 +197,10 @@ api.MapPut("/hotel", async (SettingsInput input, HttpContext c) =>
|
||||
hotel.Name = input.Name.Trim(); hotel.Signature = input.Signature; hotel.Timezone = input.Timezone; hotel.Version = input.Version + 1;
|
||||
if (!await store.Replace(hotel.HotelId, hotel.Id, input.Version, hotel)) return Input.Conflict();
|
||||
await Session.Audit(store, c, "Updated hotel settings"); return Results.Ok(hotel);
|
||||
}).RequireAuthorization("Owner");
|
||||
api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Conversation>(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt)));
|
||||
api.MapGet("/conversations/page",async(string? cursor,HttpContext c)=>ConversationPaging.TryDecode(cursor,out var before,out var beforeId)?Results.Ok(await store.ConversationPage(Session.Hotel(c),cursor==null?null:before,beforeId,50)):Results.BadRequest(new{error="Invalid conversation cursor."}));
|
||||
api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get<Conversation>(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound());
|
||||
}).RequireAuthorization(Access.HotelSettings);
|
||||
api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Conversation>(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt))).RequireAuthorization(Access.Inbox);
|
||||
api.MapGet("/conversations/page",async(string? cursor,HttpContext c)=>ConversationPaging.TryDecode(cursor,out var before,out var beforeId)?Results.Ok(await store.ConversationPage(Session.Hotel(c),cursor==null?null:before,beforeId,50)):Results.BadRequest(new{error="Invalid conversation cursor."})).RequireAuthorization(Access.Inbox);
|
||||
api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get<Conversation>(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound()).RequireAuthorization(Access.Inbox);
|
||||
api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, HttpContext c) =>
|
||||
{
|
||||
if (!Input.Text(input.Draft, 0, 20000)) return Results.BadRequest(new { error = "Draft must be under 20,000 characters." });
|
||||
@ -179,7 +209,7 @@ api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, Http
|
||||
item.Draft = input.Draft; item.Version = input.Version + 1; if (item.Status != "Completed") item.Status = input.Draft.Length > 0 ? "DraftReady" : "NeedsAttention";
|
||||
if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict();
|
||||
await Session.Audit(store, c, "Saved a reply draft"); return Results.Ok(item);
|
||||
});
|
||||
}).RequireAuthorization(Access.Inbox);
|
||||
api.MapPut("/conversations/{id}/status", async (string id, StatusInput input, HttpContext c) =>
|
||||
{
|
||||
if (input.Status is not ("Completed" or "NeedsAttention")) return Results.BadRequest();
|
||||
@ -188,14 +218,14 @@ api.MapPut("/conversations/{id}/status", async (string id, StatusInput input, Ht
|
||||
item.Status = input.Status; item.Version = input.Version + 1;
|
||||
if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict();
|
||||
await Session.Audit(store, c, input.Status == "Completed" ? "Resolved a conversation" : "Reopened a conversation"); return Results.Ok(item);
|
||||
});
|
||||
api.MapGet("/knowledge", async (HttpContext c, CancellationToken _) => Results.Ok(await store.List<KnowledgeEntry>(Session.Hotel(c))));
|
||||
}).RequireAuthorization(Access.Inbox);
|
||||
api.MapGet("/knowledge", async (HttpContext c, CancellationToken _) => Results.Ok(await store.List<KnowledgeEntry>(Session.Hotel(c)))).RequireAuthorization(Access.Inbox);
|
||||
api.MapPost("/knowledge", async (KnowledgeInput input, HttpContext c) =>
|
||||
{
|
||||
if (!Input.Knowledge(input)) return Results.BadRequest(new { error = "Enter a title and answer within the allowed lengths." });
|
||||
var item = new KnowledgeEntry { HotelId = Session.Hotel(c), Title = input.Title, Category = input.Category, Answer = input.Answer, Keywords = input.Keywords, Approved = input.Approved };
|
||||
await store.Insert(item); await Session.Audit(store, c, "Added a hotel knowledge entry"); return Results.Ok(item);
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.Knowledge);
|
||||
api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContext c) =>
|
||||
{
|
||||
if (!Input.Knowledge(input)) return Results.BadRequest();
|
||||
@ -203,9 +233,9 @@ api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContex
|
||||
item.Title = input.Title; item.Category = input.Category; item.Answer = input.Answer; item.Keywords = input.Keywords; item.Approved = input.Approved; item.Version = input.Version + 1;
|
||||
if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict();
|
||||
await Session.Audit(store, c, "Updated hotel knowledge"); return Results.Ok(item);
|
||||
}).RequireAuthorization("Owner");
|
||||
api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Activity>(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100)));
|
||||
api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => { var hotel=Session.Hotel(c);return Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, timeZone=(await store.Get<Hotel>(hotel,hotel))?.Timezone??"UTC", items = (await store.List<Mailbox>(hotel)).Select(MailboxManagement.View) }); });
|
||||
}).RequireAuthorization(Access.Knowledge);
|
||||
api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Activity>(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100))).RequireAuthorization(Access.Audit);
|
||||
api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => { var hotel=Session.Hotel(c);return Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, timeZone=(await store.Get<Hotel>(hotel,hotel))?.Timezone??"UTC", items = (await store.List<Mailbox>(hotel)).Select(MailboxManagement.View) }); }).RequireAuthorization(Access.Inbox);
|
||||
api.MapPut("/reply-controls", async (ReplyControlsInput input, HttpContext c, GoogleMailbox google, AiDrafts ai) =>
|
||||
{
|
||||
if ((input.AiDraftsEnabled && (preview || !ai.Configured)) || (input.StaffSendingEnabled && (preview || !google.SendingConfigured))) return Results.BadRequest(new { error = "The administrator must configure this capability first." });
|
||||
@ -213,7 +243,7 @@ api.MapPut("/reply-controls", async (ReplyControlsInput input, HttpContext c, Go
|
||||
hotel.AiDraftsEnabled = input.AiDraftsEnabled; hotel.StaffSendingEnabled = input.StaffSendingEnabled; hotel.ReplyMode = input.StaffSendingEnabled ? "StaffApproved" : "DraftOnly"; hotel.Version = input.Version + 1;
|
||||
if (!await store.Replace(hotel.HotelId, hotel.Id, input.Version, hotel)) return Input.Conflict();
|
||||
await Session.Audit(store, c, "Updated AI and staff sending controls"); return Results.Ok(hotel);
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.Integrations);
|
||||
api.MapPost("/conversations/{id}/generate", async (string id, VersionInput input, HttpContext c, AiDrafts ai) =>
|
||||
{
|
||||
var item = await store.Get<Conversation>(Session.Hotel(c), id); if (item == null) return Results.NotFound();
|
||||
@ -233,7 +263,7 @@ api.MapPost("/conversations/{id}/generate", async (string id, VersionInput input
|
||||
item.Status = result.NeedsReview ? "NeedsAttention" : "DraftReady"; item.Version++;
|
||||
if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict();
|
||||
await Session.Audit(store, c, result.NeedsReview ? "AI requested staff handling" : "Generated a draft for staff review"); return Results.Ok(item);
|
||||
}).RequireRateLimiting("ai");
|
||||
}).RequireAuthorization(Access.Inbox).RequireRateLimiting("ai");
|
||||
api.MapPost("/conversations/{id}/send", async (string id, SendInput input, HttpContext c, GoogleMailbox google) =>
|
||||
{
|
||||
var item = await store.Get<Conversation>(Session.Hotel(c), id); if (item == null) return Results.NotFound();
|
||||
@ -247,13 +277,13 @@ api.MapPost("/conversations/{id}/send", async (string id, SendInput input, HttpC
|
||||
item.Version++;
|
||||
if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict();
|
||||
await Session.Audit(store, c, "Approved a saved reply for Gmail delivery"); return Results.Ok(item);
|
||||
});
|
||||
}).RequireAuthorization(Access.Send);
|
||||
api.MapPost("/conversations/{id}/delivery/release",async(string id,VersionInput input,HttpContext c,AutoReplyWork work)=>
|
||||
{
|
||||
var item=await store.Get<Conversation>(Session.Hotel(c),id);if(item==null)return Results.NotFound();
|
||||
if(preview||!await work.ReturnToStaff(item,input.Version))return Input.Conflict();
|
||||
await Session.Audit(store,c,"Returned an unsubmitted automatic reply to staff review");return Results.Ok(item);
|
||||
});
|
||||
}).RequireAuthorization(Access.Integrations);
|
||||
api.MapPost("/conversations/{id}/delivery/retry", async (string id, VersionInput input, HttpContext c) =>
|
||||
{
|
||||
var item = await store.Get<Conversation>(Session.Hotel(c), id); if (item == null) return Results.NotFound();
|
||||
@ -264,7 +294,7 @@ api.MapPost("/conversations/{id}/delivery/retry", async (string id, VersionInput
|
||||
item.Delivery.State = "Pending"; item.Delivery.UpdatedAt = DateTime.UtcNow; item.Version++;
|
||||
if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict();
|
||||
await Session.Audit(store, c, "Retried a reply that had not reached Gmail sending"); return Results.Ok(item);
|
||||
});
|
||||
}).RequireAuthorization(Access.Integrations);
|
||||
api.MapPost("/conversations/{id}/delivery/verify", async (string id, VersionInput input, HttpContext c, GoogleMailbox google) =>
|
||||
{
|
||||
var item = await store.Get<Conversation>(Session.Hotel(c), id); if (item == null) return Results.NotFound();
|
||||
@ -275,13 +305,13 @@ api.MapPost("/conversations/{id}/delivery/verify", async (string id, VersionInpu
|
||||
item.Delivery.ProviderId = found; item.Delivery.State = "Sent"; item.Delivery.Detail = "Verified in Gmail Sent"; item.Delivery.UpdatedAt = DateTime.UtcNow; item.Status = "Completed"; item.Version++;
|
||||
if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict();
|
||||
await Session.Audit(store, c, "Verified uncertain delivery in Gmail Sent"); return Results.Ok(item);
|
||||
});
|
||||
}).RequireAuthorization(Access.Integrations);
|
||||
api.MapPost("/integrations/google/connect", async (HttpContext c, GoogleMailbox google) =>
|
||||
{
|
||||
if (preview || !google.Configured) return Results.BadRequest(new { error = "Google connection has not been configured by the administrator." });
|
||||
var state = new OAuthRequest { Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)), HotelId = Session.Hotel(c), UserId = c.User.FindFirstValue(ClaimTypes.NameIdentifier)!, ExpiresAt = DateTime.UtcNow.AddMinutes(10) };
|
||||
await store.Insert(state); return Results.Ok(new { url = google.AuthorizationUrl(state.Id) });
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.Integrations);
|
||||
api.MapGet("/integrations/google/callback", async (HttpContext c, GoogleMailbox google) =>
|
||||
{
|
||||
if (preview) return Results.BadRequest();
|
||||
@ -291,7 +321,7 @@ api.MapGet("/integrations/google/callback", async (HttpContext c, GoogleMailbox
|
||||
try { await google.Connect(Session.Hotel(c), c.Request.Query["code"].ToString(),state.StartedAt,state.ExpectedEmail); await Session.Audit(store, c, "Connected Google mailbox"); }
|
||||
catch { return Results.Redirect("/settings?google=failed"); }
|
||||
return Results.Redirect("/settings?google=connected");
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.Integrations);
|
||||
app.UseDefaultFiles(); app.UseStaticFiles();
|
||||
app.MapFallbackToFile("index.html");
|
||||
app.Run();
|
||||
@ -301,7 +331,7 @@ namespace GuestOps.Web
|
||||
public static class Session
|
||||
{
|
||||
public static string Hotel(HttpContext c) => c.User.FindFirstValue("hotel") ?? throw new InvalidOperationException("Missing hotel membership");
|
||||
public static Task SignIn(HttpContext c, StaffUser u) => c.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, u.Id), new Claim(ClaimTypes.Name, u.Name), new Claim(ClaimTypes.Role, u.Role), new Claim("hotel", u.HotelId), new Claim("security_stamp", u.SecurityStamp) }, CookieAuthenticationDefaults.AuthenticationScheme)));
|
||||
public static Task SignIn(HttpContext c, StaffUser u,bool mfa=false) => c.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, u.Id), new Claim(ClaimTypes.Name, u.Name), new Claim(ClaimTypes.Role, Access.NormalizeRole(u.Role)), new Claim("hotel", u.HotelId), new Claim("security_stamp", u.SecurityStamp),new Claim("mfa",mfa?"true":"false") }, CookieAuthenticationDefaults.AuthenticationScheme)));
|
||||
public static Task Audit(IStore store, HttpContext c, string action) => store.Insert(new Activity { HotelId = Hotel(c), UserName = c.User.Identity?.Name ?? "Staff", Action = action });
|
||||
}
|
||||
public static class Input
|
||||
|
||||
@ -29,6 +29,8 @@ public interface IStore
|
||||
Task ReleaseLease(string id, string owner);
|
||||
Task Import(Conversation message);
|
||||
Task<List<Conversation>> Deliveries();
|
||||
Task<List<AccountMail>> AccountMails();
|
||||
Task<AccountMail?> ClaimAccountMail(string id, string owner);
|
||||
Task<bool> TryInsertPmsChange(PmsChange change);
|
||||
Task<bool> TryInsertPayment(PaymentRequest payment);
|
||||
Task<bool> TryAutoReplyClaim(AutoReplyClaim claim);
|
||||
@ -40,6 +42,11 @@ public sealed class MongoStore : IStore
|
||||
public async Task<DateTime?> WorkerLastSeen()=>(await db.GetCollection<WorkerHeartbeat>("workerheartbeat").Find(x=>x.Id=="worker").FirstOrDefaultAsync())?.At;
|
||||
public async Task RecordWorkerHeartbeat()=>await db.GetCollection<WorkerHeartbeat>("workerheartbeat").ReplaceOneAsync(x=>x.Id=="worker",new WorkerHeartbeat(),new ReplaceOptions{IsUpsert=true});
|
||||
public Task<List<Conversation>> Deliveries() => Collection<Conversation>().Find(x => x.Delivery != null && (x.Delivery.State == "Pending" || x.Delivery.State == "Sending")).SortBy(x => x.Delivery!.UpdatedAt).Limit(100).ToListAsync();
|
||||
public Task<List<AccountMail>> AccountMails() => Collection<AccountMail>().Find(x => (x.State == "Pending" || x.State == "Sending") && x.NextAttemptAt <= DateTime.UtcNow).SortBy(x => x.NextAttemptAt).Limit(100).ToListAsync();
|
||||
public async Task<AccountMail?> ClaimAccountMail(string id,string owner)=>await Collection<AccountMail>().FindOneAndUpdateAsync(
|
||||
x=>x.Id==id&&(x.State=="Pending"||(x.State=="Sending"&&x.LeaseUntil<DateTime.UtcNow))&&x.NextAttemptAt<=DateTime.UtcNow,
|
||||
Builders<AccountMail>.Update.Set(x=>x.State,"Sending").Set(x=>x.LeaseOwner,owner).Set(x=>x.LeaseUntil,DateTime.UtcNow.AddMinutes(2)).Inc(x=>x.Version,1),
|
||||
new(){ReturnDocument=ReturnDocument.After});
|
||||
private readonly IMongoDatabase db;
|
||||
public MongoStore(IConfiguration config)
|
||||
{
|
||||
@ -57,6 +64,8 @@ public sealed class MongoStore : IStore
|
||||
public async Task Initialize()
|
||||
{
|
||||
await Collection<StaffUser>().Indexes.CreateOneAsync(new CreateIndexModel<StaffUser>(Builders<StaffUser>.IndexKeys.Ascending(x=>x.AccountLinkHash)));
|
||||
await Collection<AccountMail>().Indexes.CreateOneAsync(new CreateIndexModel<AccountMail>(Builders<AccountMail>.IndexKeys.Ascending(x=>x.State).Ascending(x=>x.NextAttemptAt)));
|
||||
await Collection<AccountMail>().Indexes.CreateOneAsync(new CreateIndexModel<AccountMail>(Builders<AccountMail>.IndexKeys.Ascending(x=>x.MessageId),new(){Unique=true}));
|
||||
foreach(var field in new[]{"ThreadKey","RecipientDay","DaySlot"})await Collection<AutoReplyClaim>().Indexes.CreateOneAsync(new CreateIndexModel<AutoReplyClaim>(Builders<AutoReplyClaim>.IndexKeys.Ascending(x=>x.HotelId).Ascending(field),new(){Unique=true}));
|
||||
await Collection<Conversation>().Indexes.CreateOneAsync(new CreateIndexModel<Conversation>(Builders<Conversation>.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.MailboxId).Ascending(x=>x.AutoReplyCheckedAt).Ascending(x=>x.ReceivedAt)));
|
||||
await Collection<PaymentRequest>().Indexes.CreateOneAsync(new CreateIndexModel<PaymentRequest>(Builders<PaymentRequest>.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.Reference),new(){Unique=true}));
|
||||
@ -179,6 +188,16 @@ public sealed class PreviewStore : IStore
|
||||
}
|
||||
}
|
||||
public Task<List<Conversation>> Deliveries() => Task.FromResult(rows.Where(x => x.Key.StartsWith("Conversation:")).Select(x => Clone<Conversation>(x.Value)).Where(x => x.Delivery?.State is "Pending" or "Sending").ToList());
|
||||
public Task<List<AccountMail>> AccountMails()=>Task.FromResult(rows.Where(x=>x.Key.StartsWith("AccountMail:")).Select(x=>Clone<AccountMail>(x.Value)).Where(x=>x.State is "Pending" or "Sending"&&x.NextAttemptAt<=DateTime.UtcNow).ToList());
|
||||
public Task<AccountMail?> ClaimAccountMail(string id,string owner)
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
if(!rows.TryGetValue(Key<AccountMail>(id),out var raw))return Task.FromResult<AccountMail?>(null);
|
||||
var mail=Clone<AccountMail>(raw);if(mail.NextAttemptAt>DateTime.UtcNow||mail.State!="Pending"&&!(mail.State=="Sending"&&mail.LeaseUntil<DateTime.UtcNow))return Task.FromResult<AccountMail?>(null);
|
||||
mail.State="Sending";mail.LeaseOwner=owner;mail.LeaseUntil=DateTime.UtcNow.AddMinutes(2);mail.Version++;rows[Key<AccountMail>(id)]=Json(mail);return Task.FromResult<AccountMail?>(mail);
|
||||
}
|
||||
}
|
||||
private readonly ConcurrentDictionary<string, string> rows = new();
|
||||
private readonly object gate = new();
|
||||
static string Key<T>(string id) => typeof(T).Name + ":" + id;
|
||||
|
||||
@ -3,15 +3,15 @@ using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
namespace GuestOps.Web;
|
||||
public sealed record InviteInput(string Name,string Email);
|
||||
public sealed record InviteInput(string Name,string Email,string Role="Agent");
|
||||
public sealed record AccountTokenInput(string Token);
|
||||
public sealed record AccountAcceptInput(string Token,string Password,string ConfirmPassword);
|
||||
public sealed record AccountLinkResult(string UserId,string Link,DateTime ExpiresAt);
|
||||
public sealed record AccountLinkResult(string UserId,string Link,DateTime ExpiresAt,string DeliveryState="Preview");
|
||||
public sealed class AccountInvalid(string message):Exception(message);
|
||||
public sealed class AccountConflict(string message):Exception(message);
|
||||
public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,IConfiguration config)
|
||||
public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,IConfiguration config,AccountMailService? mail=null)
|
||||
{
|
||||
public static object View(StaffUser user)=>new {user.Id,user.Name,user.Email,user.Role,user.Active,user.Version,pending=user.PasswordHash.Length==0,linkPurpose=user.AccountLinkPurpose,linkExpiresAt=user.AccountLinkExpiresAt};
|
||||
public static object View(StaffUser user)=>new {user.Id,user.Name,user.Email,role=Access.NormalizeRole(user.Role),legacyRole=user.Role=="Staff",user.Active,user.Version,pending=user.PasswordHash.Length==0,mfaEnabled=user.MfaEnabledAt!=null,linkPurpose=user.AccountLinkPurpose,linkExpiresAt=user.AccountLinkExpiresAt};
|
||||
public static bool SessionValid(StaffUser user,string? stamp)=>user.Active&&user.SecurityStamp==(stamp??"");
|
||||
public static bool PasswordValid(string? password)=>password!=null&&password.Length>=14&&password.Length<=128;
|
||||
static string Hash(string token)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
@ -27,19 +27,20 @@ public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,
|
||||
if(!Input.Text(input.Name,2,100)||!Input.Text(input.Email,3,254))throw new AccountInvalid("Enter a staff name and email address.");
|
||||
var email=input.Email.Trim().ToLowerInvariant();if(!Input.Email(email)||email.Any(char.IsControl))throw new AccountInvalid("Enter one plain staff email address.");
|
||||
_=BaseUrl();
|
||||
var role=Access.NormalizeRole(input.Role);if(role==Access.Owner||!Access.Roles.Contains(role))throw new AccountInvalid("Choose Manager, Agent, or Auditor.");
|
||||
var user=await store.FindLogin(email);
|
||||
if(user!=null&&(user.HotelId!=hotel||user.Role!="Staff"||user.PasswordHash.Length>0))throw new AccountInvalid("This email is unavailable for invitation. Contact the administrator.");
|
||||
if(user!=null&&(user.HotelId!=hotel||Access.NormalizeRole(user.Role)==Access.Owner||user.PasswordHash.Length>0))throw new AccountInvalid("This email is unavailable for invitation. Contact the administrator.");
|
||||
if(user==null)
|
||||
{
|
||||
if((await store.List<StaffUser>(hotel)).Count>=50)throw new AccountInvalid("This hotel has reached the 50-account pilot limit. Contact the administrator.");
|
||||
user=new StaffUser{HotelId=hotel,Name=input.Name.Trim(),Email=email,Role="Staff",Active=false};
|
||||
user=new StaffUser{HotelId=hotel,Name=input.Name.Trim(),Email=email,Role=role,RoleVersion=1,Active=false};
|
||||
if(!await store.TryInsertStaff(user))throw new AccountConflict("The account changed elsewhere. Refresh the team list.");
|
||||
}
|
||||
user.Name=input.Name.Trim();return await Issue(user,"Invite",TimeSpan.FromHours(48));
|
||||
user.Name=input.Name.Trim();user.Role=role;return await Issue(user,"Invite",TimeSpan.FromHours(48));
|
||||
}
|
||||
public Task<AccountLinkResult> ResetStaff(StaffUser user,long version)
|
||||
{
|
||||
if(user.Role!="Staff"||!user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only the current active staff account can receive a recovery link.");
|
||||
if(Access.NormalizeRole(user.Role)==Access.Owner||!user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only a current active team account can receive a recovery link.");
|
||||
return Issue(user,"Reset",TimeSpan.FromMinutes(30));
|
||||
}
|
||||
public Task<AccountLinkResult> RecoverOwner(StaffUser user)
|
||||
@ -48,7 +49,7 @@ public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,
|
||||
}
|
||||
public Task<AccountLinkResult> Restore(StaffUser user,long version)
|
||||
{
|
||||
if(user.Role!="Staff"||user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only the current disabled staff account can be restored.");
|
||||
if(Access.NormalizeRole(user.Role)==Access.Owner||user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only a current disabled team account can be restored.");
|
||||
return Issue(user,"Restore",TimeSpan.FromHours(48));
|
||||
}
|
||||
async Task<AccountLinkResult> Issue(StaffUser user,string purpose,TimeSpan lifetime)
|
||||
@ -56,36 +57,55 @@ public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,
|
||||
var root=BaseUrl();var token=Convert.ToHexString(RandomNumberGenerator.GetBytes(32));var version=user.Version;
|
||||
user.AccountLinkHash=Hash(token);user.AccountLinkPurpose=purpose;user.AccountLinkExpiresAt=DateTime.UtcNow.Add(lifetime);user.Version++;
|
||||
if(!await store.Replace(user.HotelId,user.Id,version,user))throw new AccountConflict("The account changed elsewhere. Refresh and issue a new link.");
|
||||
return new(user.Id,root+"/account#token="+token,user.AccountLinkExpiresAt.Value);
|
||||
var link=root+"/account#token="+token;
|
||||
if(mail!=null&&!mail.Preview)
|
||||
{
|
||||
var subject=purpose=="Invite"?"Your GuestOps invitation":"Your GuestOps account recovery link";
|
||||
await mail.Queue(user,purpose,subject,"This single-use link expires at "+user.AccountLinkExpiresAt.Value.ToString("O")+".",link,user.AccountLinkExpiresAt.Value);
|
||||
return new(user.Id,link,user.AccountLinkExpiresAt.Value,"Pending");
|
||||
}
|
||||
return new(user.Id,link,user.AccountLinkExpiresAt.Value,"Preview");
|
||||
}
|
||||
public async Task<StaffUser?> Inspect(string? token)
|
||||
{
|
||||
if(token==null||!Regex.IsMatch(token,"^[A-F0-9]{64}$"))return null;
|
||||
var user=await store.FindAccountLink(Hash(token));
|
||||
if(user==null||user.AccountLinkExpiresAt<=DateTime.UtcNow||user.AccountLinkExpiresAt==null)return null;
|
||||
if(user.AccountLinkPurpose=="Invite"&&user.Role=="Staff"&&!user.Active&&user.PasswordHash.Length==0)return user;
|
||||
if(user.AccountLinkPurpose=="Restore"&&user.Role=="Staff"&&!user.Active&&user.PasswordHash.Length>0)return user;
|
||||
if(user.AccountLinkPurpose=="Invite"&&Access.NormalizeRole(user.Role)!=Access.Owner&&!user.Active&&user.PasswordHash.Length==0)return user;
|
||||
if(user.AccountLinkPurpose=="Restore"&&Access.NormalizeRole(user.Role)!=Access.Owner&&!user.Active&&user.PasswordHash.Length>0)return user;
|
||||
return user.AccountLinkPurpose=="Reset"&&user.Active&&user.PasswordHash.Length>0?user:null;
|
||||
}
|
||||
public async Task<bool> Accept(AccountAcceptInput input)
|
||||
{
|
||||
if(!PasswordValid(input.Password)||input.Password!=input.ConfirmPassword)throw new AccountInvalid("Use matching passwords of 14 to 128 characters.");
|
||||
var user=await Inspect(input.Token);if(user==null)return false;
|
||||
var hash=user.AccountLinkHash;var version=user.Version;
|
||||
var hash=user.AccountLinkHash;var version=user.Version;var purpose=user.AccountLinkPurpose;
|
||||
user.PasswordHash=hasher.HashPassword(user,input.Password);user.Active=true;user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++;
|
||||
if(purpose=="Invite")user.EmailVerifiedAt=DateTime.UtcNow;
|
||||
user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;
|
||||
return await store.ConsumeAccountLink(user,version,hash);
|
||||
var changed=await store.ConsumeAccountLink(user,version,hash);if(changed&&mail!=null)await mail.Notify(user,"PasswordChanged","Your GuestOps password was changed. Contact the hotel owner immediately if this was not you.");return changed;
|
||||
}
|
||||
public async Task<bool> Disable(StaffUser user,long version)
|
||||
{
|
||||
if(user.Role!="Staff"||user.Version!=version)return false;
|
||||
if(Access.NormalizeRole(user.Role)==Access.Owner||user.Version!=version)return false;
|
||||
user.Active=false;user.SecurityStamp=Guid.NewGuid().ToString("N");user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;user.Version++;
|
||||
return await store.Replace(user.HotelId,user.Id,version,user);
|
||||
var changed=await store.Replace(user.HotelId,user.Id,version,user);if(changed&&mail!=null)await mail.Notify(user,"AccountDisabled","Your GuestOps account was disabled.");return changed;
|
||||
}
|
||||
public async Task<bool> Revoke(StaffUser user,long version)
|
||||
{
|
||||
if(user.Role!="Staff"||user.Version!=version)return false;
|
||||
if(Access.NormalizeRole(user.Role)==Access.Owner||user.Version!=version)return false;
|
||||
user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;user.Version++;
|
||||
return await store.Replace(user.HotelId,user.Id,version,user);
|
||||
}
|
||||
}
|
||||
public async Task<bool> ChangeRole(StaffUser user,long version,string role)
|
||||
{
|
||||
role=Access.NormalizeRole(role);if(role==Access.Owner||!Access.Roles.Contains(role)||user.Version!=version||Access.NormalizeRole(user.Role)==Access.Owner)return false;
|
||||
user.Role=role;user.RoleVersion=1;user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++;
|
||||
var changed=await store.Replace(user.HotelId,user.Id,version,user);if(changed&&mail!=null)await mail.Notify(user,"RoleChanged","Your GuestOps role changed to "+role+".");return changed;
|
||||
}
|
||||
public async Task<bool> ResetMfa(StaffUser user,long version)
|
||||
{
|
||||
if(Access.NormalizeRole(user.Role)==Access.Owner||user.Version!=version)return false;
|
||||
MfaService.Reset(user);user.Version++;var changed=await store.Replace(user.HotelId,user.Id,version,user);if(changed&&mail!=null)await mail.Notify(user,"MfaReset","MFA was reset for your GuestOps account. You must enroll again before the next MFA-enforced sign-in.");return changed;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using System.Security.Claims;
|
||||
namespace GuestOps.Web;
|
||||
public static class TeamEndpoints
|
||||
{
|
||||
static object Delivery(AccountLinkResult result,bool preview)=>preview?new{result.UserId,result.DeliveryState,result.ExpiresAt,result.Link}:(object)new{result.UserId,result.DeliveryState,result.ExpiresAt};
|
||||
public static void Map(WebApplication app,RouteGroupBuilder api,bool preview)
|
||||
{
|
||||
app.MapPost("/api/account-links/inspect",async(AccountTokenInput input,TeamAccounts accounts,IStore store)=>
|
||||
@ -17,25 +19,36 @@ public static class TeamEndpoints
|
||||
await store.Insert(new Activity{HotelId=user.HotelId,UserName=user.Name,Action=user.AccountLinkPurpose=="Invite"?"Accepted staff invitation":"Changed account password"});
|
||||
await c.SignOutAsync();return Results.Ok();
|
||||
}).RequireRateLimiting("accounts");
|
||||
var team=api.MapGroup("/team").RequireAuthorization("Owner");
|
||||
var team=api.MapGroup("/team").RequireAuthorization(Access.Team);
|
||||
team.MapGet("/",async(HttpContext c,IStore store)=>Results.Ok((await store.List<StaffUser>(Session.Hotel(c))).Select(TeamAccounts.View)));
|
||||
team.MapPost("/invite",async(InviteInput input,HttpContext c,IStore store,TeamAccounts accounts)=>
|
||||
{
|
||||
var result=await accounts.Invite(Session.Hotel(c),input);await Session.Audit(store,c,"Issued a staff invitation link");return Results.Ok(result);
|
||||
var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";var role=Access.NormalizeRole(input.Role);
|
||||
if(!Access.CanManage(actor,role))return Results.Forbid();
|
||||
var result=await accounts.Invite(Session.Hotel(c),input with{Role=role});await Session.Audit(store,c,"Issued a team invitation");return Results.Ok(Delivery(result,preview));
|
||||
}).RequireRateLimiting("accounts");
|
||||
team.MapPost("/{id}/{action}",async(string id,string action,VersionInput input,HttpContext c,IStore store,TeamAccounts accounts)=>
|
||||
{
|
||||
var user=await store.Get<StaffUser>(Session.Hotel(c),id);if(user==null)return Results.NotFound();
|
||||
if(user.Role!="Staff")return Results.BadRequest(new {error="Owner accounts are managed by the server administrator."});
|
||||
var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";if(!Access.CanManage(actor,user.Role))return Results.Forbid();
|
||||
if(action is "reset" or "restore")
|
||||
{
|
||||
var result=action=="reset"?await accounts.ResetStaff(user,input.Version):await accounts.Restore(user,input.Version);
|
||||
await Session.Audit(store,c,action=="reset"?"Issued a staff password recovery link":"Issued a staff restoration link");return Results.Ok(result);
|
||||
await Session.Audit(store,c,action=="reset"?"Issued a team password recovery":"Issued a team restoration");return Results.Ok(Delivery(result,preview));
|
||||
}
|
||||
if(action=="mfa-reset")
|
||||
{
|
||||
if(Access.NormalizeRole(actor)!=Access.Owner)return Results.Forbid();if(!await accounts.ResetMfa(user,input.Version))return Input.Conflict();await Session.Audit(store,c,"Reset team MFA after identity verification");return Results.Ok();
|
||||
}
|
||||
if(action is not ("disable" or "revoke"))return Results.NotFound();
|
||||
if(!(action=="disable"?await accounts.Disable(user,input.Version):await accounts.Revoke(user,input.Version)))return Input.Conflict();
|
||||
await Session.Audit(store,c,action=="disable"?"Disabled a staff account":"Revoked a staff account link");return Results.Ok();
|
||||
await Session.Audit(store,c,action=="disable"?"Disabled a team account":"Revoked a team account link");return Results.Ok();
|
||||
}).RequireRateLimiting("accounts");
|
||||
team.MapPut("/{id}/role",async(string id,RoleInput input,HttpContext c,IStore store,TeamAccounts accounts)=>
|
||||
{
|
||||
var user=await store.Get<StaffUser>(Session.Hotel(c),id);if(user==null)return Results.NotFound();var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";var role=Access.NormalizeRole(input.Role);
|
||||
if(!Access.CanManage(actor,user.Role)||!Access.CanManage(actor,role))return Results.Forbid();if(!await accounts.ChangeRole(user,input.Version,role))return Input.Conflict();await Session.Audit(store,c,"Changed a team role to "+role);return Results.Ok(TeamAccounts.View(user));
|
||||
});
|
||||
api.MapGet("/onboarding",async(HttpContext c,IStore store)=>
|
||||
{
|
||||
var id=Session.Hotel(c);var hotel=await store.Get<Hotel>(id,id);
|
||||
@ -44,10 +57,11 @@ public static class TeamEndpoints
|
||||
new {title="Check your hotel details",detail="Review the hotel name, timezone and email signature.",path="/settings",complete=hotel!=null&&hotel.Name.Length>=2&&hotel.Signature.Length>0,optional=false},
|
||||
new {title="Approve your guest answers",detail="Add current check-in, parking and breakfast information.",path="/knowledge",complete=knowledge.Any(x=>x.Approved),optional=false},
|
||||
new {title="Connect the hotel mailbox",detail="Connect Google and check that guest messages appear in the inbox.",path="/settings",complete=mailboxes.Any(x=>x.Status=="Connected"&&x.LastSyncAt!=null),optional=false},
|
||||
new {title="Invite your team",detail="Give each colleague their own account. Owners keep control of integrations and automation.",path="/team",complete=users.Any(x=>x.Role=="Staff"&&x.Active),optional=true},
|
||||
new {title="Invite your team",detail="Give each colleague their own account. Owners keep control of integrations and automation.",path="/team",complete=users.Any(x=>Access.NormalizeRole(x.Role)!=Access.Owner&&x.Active),optional=true},
|
||||
new {title="Test FAQ automation",detail="Review test results before enabling live replies. This checklist does not enable sending.",path="/automation",complete=hotel?.AutoReplyMode=="Test"||hotel?.AutoReplyMode=="Live",optional=true}
|
||||
}});
|
||||
}).RequireAuthorization("Owner");
|
||||
}).RequireAuthorization(Access.HotelSettings);
|
||||
}
|
||||
}
|
||||
public sealed record RoleInput(string Role,long Version);
|
||||
|
||||
|
||||
@ -14,12 +14,36 @@ builder.Services.AddDataProtection().SetApplicationName("GuestOps-Web").PersistK
|
||||
builder.Services.AddHttpClient<GoogleMailbox>(c => c.Timeout = TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
builder.Services.AddTransient<ReplyDelivery>();
|
||||
builder.Services.AddTransient<AutoReplyWork>();
|
||||
builder.Services.AddTransient<AccountMailService>();
|
||||
builder.Services.AddTransient<IAccountMailTransport,SmtpAccountMailTransport>();
|
||||
builder.Services.AddTransient<AccountMailProcessor>();
|
||||
builder.Services.AddHostedService<AccountMailWorker>();
|
||||
builder.Services.AddHostedService<AutoReplyWorker>();
|
||||
builder.Services.AddHostedService<DeliveryWorker>();
|
||||
builder.Services.AddHostedService<MailboxWorker>();
|
||||
builder.Services.AddHostedService<HeartbeatWorker>();
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
sealed class AccountMailWorker(IStore store,IServiceScopeFactory factory,ILogger<AccountMailWorker> log):BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while(!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach(var item in await store.AccountMails())
|
||||
{
|
||||
using var scope=factory.CreateScope();using var deadline=CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);deadline.CancelAfter(TimeSpan.FromMinutes(1));
|
||||
await scope.ServiceProvider.GetRequiredService<AccountMailProcessor>().Process(item,deadline.Token);
|
||||
}
|
||||
}
|
||||
catch(Exception ex) when(!stoppingToken.IsCancellationRequested){log.LogWarning("Account mail cycle paused ({Type})",ex.GetType().Name);}
|
||||
await Task.Delay(TimeSpan.FromSeconds(10),stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class HeartbeatWorker(IStore store,ILogger<HeartbeatWorker> log):BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
|
||||
49
tests/GuestOps.Tests/IdentitySecurityTests.cs
Normal file
49
tests/GuestOps.Tests/IdentitySecurityTests.cs
Normal file
@ -0,0 +1,49 @@
|
||||
using GuestOps.Web;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Net.Mail;
|
||||
|
||||
public static class IdentitySecurityTests
|
||||
{
|
||||
sealed class FakeTransport(Func<int,Exception?> outcome):IAccountMailTransport
|
||||
{
|
||||
public int Calls;
|
||||
public Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken){Calls++;var error=outcome(Calls);return error==null?Task.CompletedTask:Task.FromException(error);}
|
||||
}
|
||||
public static async Task Run(Action<string,bool> check,IStore store)
|
||||
{
|
||||
var secret=System.Text.Encoding.ASCII.GetBytes("12345678901234567890");
|
||||
check("TOTP matches RFC 6238 SHA1 vector truncated to six digits",Totp.Code(secret,1)=="287082"&&Totp.Code(secret,37037036)=="081804");
|
||||
var now=DateTimeOffset.FromUnixTimeSeconds(1_234_567_890).UtcDateTime;var step=Totp.Step(now);var current=Totp.Code(secret,step);
|
||||
check("TOTP accepts a current code",Totp.Verify(secret,current,now,null)==step);
|
||||
check("TOTP accepts the approved one-step clock window",Totp.Verify(secret,Totp.Code(secret,step-1),now,null)==step-1&&Totp.Verify(secret,Totp.Code(secret,step+1),now,null)==step+1);
|
||||
check("TOTP rejects malformed and out-of-window codes",Totp.Verify(secret,"12345x",now,null)==null&&Totp.Verify(secret,Totp.Code(secret,step+2),now,null)==null);
|
||||
check("TOTP rejects replayed time steps",Totp.Verify(secret,current,now,step)==null);
|
||||
check("Base32 secret round-trips",Totp.Decode(Totp.Encode(secret)).SequenceEqual(secret));
|
||||
check("Legacy Staff role has Agent permissions",Access.NormalizeRole("Staff")==Access.Agent&&Access.Permissions("Staff").Contains(Access.Inbox)&&!Access.Permissions("Staff").Contains(Access.Team));
|
||||
check("Auditor cannot access guest workflows",Access.Permissions(Access.Auditor).Contains(Access.Audit)&&!Access.Permissions(Access.Auditor).Contains(Access.Inbox));
|
||||
check("Managers cannot manage Managers",Access.CanManage(Access.Manager,Access.Agent)&&Access.CanManage(Access.Manager,Access.Auditor)&&!Access.CanManage(Access.Manager,Access.Manager));
|
||||
|
||||
var protection=new EphemeralDataProtectionProvider();var hotel=Guid.NewGuid().ToString("N");var user=new StaffUser{HotelId=hotel,Email=hotel+"@example.invalid",Name="MFA user",SecurityStamp="initial",Role=Access.Agent};await store.Insert(user);
|
||||
var mfa=new MfaService(store,protection);var enrollment=await mfa.Begin(user);var enrollmentSecret=Totp.Decode(enrollment.Secret);user=(await store.Get<StaffUser>(hotel,user.Id))!;var code=Totp.Code(enrollmentSecret,Totp.Step(DateTime.UtcNow));
|
||||
var attempts=await Task.WhenAll(Enumerable.Range(0,4).Select(async _=>{var copy=(await store.Get<StaffUser>(hotel,user.Id))!;return await mfa.Enable(copy,code);}));
|
||||
check("Concurrent MFA enrollment verification succeeds once",attempts.Count(x=>x!=null)==1);
|
||||
var codes=attempts.Single(x=>x!=null)!;user=(await store.Get<StaffUser>(hotel,user.Id))!;
|
||||
check("MFA stores protected secret and hashed recovery codes",user.MfaSecretProtected.Length>0&&!user.MfaSecretProtected.Contains(enrollment.Secret)&&user.RecoveryCodeHashes.Length==10&&codes.All(x=>!user.RecoveryCodeHashes.Contains(x)));
|
||||
var used=await mfa.UseRecovery(user,codes[0]);check("Recovery code is consumed once",used!=null&&await mfa.UseRecovery((await store.Get<StaffUser>(hotel,user.Id))!,codes[0])==null);
|
||||
|
||||
var mailConfig=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{{"Preview","false"}}).Build();var mail=new AccountMailService(store,protection,mailConfig);
|
||||
var queued=await mail.Queue(user,"Test","Security notice","Safe body","https://example.invalid/private-token",DateTime.UtcNow.AddMinutes(5));
|
||||
check("Account mail protects address body and token at rest",!queued.ProtectedPayload.Contains(user.Email)&&!queued.ProtectedPayload.Contains("private-token")&&queued.MessageId.StartsWith('<'));
|
||||
var success=new FakeTransport(_=>null);await new AccountMailProcessor(store,mail,success,NullLogger<AccountMailProcessor>.Instance).Process(queued,CancellationToken.None);
|
||||
check("Account mail completes one claimed delivery",(await store.Get<AccountMail>(hotel,queued.Id))?.State=="Sent"&&success.Calls==1);
|
||||
var ambiguous=await mail.Queue(user,"Test","Notice","Body","",DateTime.UtcNow.AddMinutes(5));var uncertainTransport=new FakeTransport(_=>new SmtpException("ambiguous"));
|
||||
await new AccountMailProcessor(store,mail,uncertainTransport,NullLogger<AccountMailProcessor>.Instance).Process(ambiguous,CancellationToken.None);
|
||||
check("Ambiguous SMTP outcome is never automatically retried",(await store.Get<AccountMail>(hotel,ambiguous.Id))?.State=="NeedsReview");
|
||||
var transient=await mail.Queue(user,"Test","Notice","Body","",DateTime.UtcNow.AddMinutes(5));var preSubmit=new FakeTransport(_=>new MailPreSubmissionException("not submitted"));
|
||||
await new AccountMailProcessor(store,mail,preSubmit,NullLogger<AccountMailProcessor>.Instance).Process(transient,CancellationToken.None);var pending=await store.Get<AccountMail>(hotel,transient.Id);
|
||||
check("Clearly pre-submission mail failure receives bounded retry",pending?.State=="Pending"&&pending.AttemptCount==1&&pending.NextAttemptAt>DateTime.UtcNow);
|
||||
check("Preference filters are fixed presets",IdentityEndpoints.ValidFilter("DraftReady")&&!IdentityEndpoints.ValidFilter("OwnerOnly"));
|
||||
}
|
||||
}
|
||||
@ -23,7 +23,8 @@ try
|
||||
await store.Ping();
|
||||
if(uri!=null){await store.RecordWorkerHeartbeat();Check("Worker heartbeat persists in MongoDB",await store.WorkerLastSeen()>DateTime.UtcNow.AddMinutes(-1));}
|
||||
await MailboxTests.Run(Check, store);
|
||||
await TeamTests.Run(Check, store);
|
||||
await TeamTests.Run(Check, store);
|
||||
await IdentitySecurityTests.Run(Check, store);
|
||||
await ReplyTests.Run(Check, store);
|
||||
await PmsTests.Run(Check, store);
|
||||
await PaymentTests.Run(Check, store);
|
||||
|
||||
@ -14,7 +14,7 @@ public static class TeamTests
|
||||
var hasher=new PasswordHasher<StaffUser>();var service=new TeamAccounts(store,hasher,config);
|
||||
var link=await service.Invite(hotel,new("Test Colleague",email));var token=Token(link);
|
||||
var user=(await store.Get<StaffUser>(hotel,link.UserId))!;
|
||||
check("Invitation stores hash and creates inactive Staff only",user.Role=="Staff"&&!user.Active&&user.PasswordHash==""&&user.AccountLinkHash!=token&&user.AccountLinkHash.Length==64);
|
||||
check("Invitation stores hash and creates inactive Agent only",user.Role=="Agent"&&!user.Active&&user.PasswordHash==""&&user.AccountLinkHash!=token&&user.AccountLinkHash.Length==64);
|
||||
check("Account link uses configured HTTPS origin and fragment",link.Link.StartsWith("https://hotel.example.invalid/account#token=")&&!link.Link.Contains('?'));
|
||||
check("Token inspection rejects malformed token",await service.Inspect("bad")==null);
|
||||
bool denied=false;try{await service.Invite("foreign",new("Other Colleague",email));}catch(AccountInvalid){denied=true;}check("Invitation cannot claim another hotel's account",denied);
|
||||
|
||||
30
tests/test_account_security_acceptance.py
Normal file
30
tests/test_account_security_acceptance.py
Normal file
@ -0,0 +1,30 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location("account_security_acceptance",ROOT/"deploy"/"account_security_acceptance.py")
|
||||
validator=importlib.util.module_from_spec(spec);spec.loader.exec_module(validator)
|
||||
|
||||
def valid_record():
|
||||
record=json.loads((ROOT/"deploy"/"account-security-acceptance.example.json").read_text(encoding="utf-8"))
|
||||
record.update(operator="Operator Name",reviewedBy="Independent Reviewer",securityReviewedBy="Security Reviewer",securityReviewEvidence=["restricted/security-review-001"])
|
||||
record["productionControls"]["enabledAfterSecurityReview"]=True
|
||||
for scenario in record["scenarios"]:scenario.update(status="pass",evidence=["restricted/"+scenario["id"]])
|
||||
return record
|
||||
|
||||
class AccountSecurityAcceptanceTests(unittest.TestCase):
|
||||
def test_complete_record_passes(self): validator.validate(valid_record())
|
||||
def test_missing_scenario_fails(self):
|
||||
record=valid_record();record["scenarios"].pop()
|
||||
with self.assertRaisesRegex(ValueError,"exact milestone 19 scenario"):validator.validate(record)
|
||||
def test_review_must_be_independent(self):
|
||||
record=valid_record();record["securityReviewedBy"]=record["operator"]
|
||||
with self.assertRaisesRegex(ValueError,"different people"):validator.validate(record)
|
||||
def test_evidence_rejects_contact_data(self):
|
||||
record=valid_record();record["scenarios"][0]["evidence"]=["person@example.invalid"]
|
||||
with self.assertRaisesRegex(ValueError,"opaque evidence"):validator.validate(record)
|
||||
|
||||
if __name__=="__main__":unittest.main()
|
||||
@ -6,8 +6,8 @@ type Status={liveConfigured:boolean;preview:boolean;questions:string[];dailyLimi
|
||||
type Decision={matches:boolean;reason:string;body:string};
|
||||
type Evaluation={total:number;passed:number;falsePositives:number;falseNegatives:number;results:{id:string;passed:boolean;reason:string}[]};
|
||||
type History={id:string;subject:string;from:string;autoReplyCheckedAt:string;autoReplyMatched:boolean;autoReplyDetail:string;delivery:string|null};
|
||||
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
type Props={hotel:Hotel;owner:boolean;canLive:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
export function AutomationPage({hotel,owner,canLive,busy,run,onHotel}:Props){
|
||||
const [status,setStatus]=useState<Status|null>(null),[rules,setRules]=useState<Rule[]>([]),[knowledge,setKnowledge]=useState<Knowledge[]>([]),[history,setHistory]=useState<History[]>([]);
|
||||
const [question,setQuestion]=useState(0),[answer,setAnswer]=useState(''),[enabled,setEnabled]=useState(false),[subject,setSubject]=useState('Parking question'),[body,setBody]=useState('Is parking available?'),[result,setResult]=useState<Decision|null>(null);
|
||||
const [evaluationText,setEvaluationText]=useState('[\n {"id":"parking-exact","subject":"Parking","body":"Is parking available?","expectedMatch":true},\n {"id":"parking-extra-request","subject":"Parking","body":"Is parking available? Also cancel my booking.","expectedMatch":false}\n]'),[evaluation,setEvaluation]=useState<Evaluation|null>(null);
|
||||
@ -18,7 +18,7 @@ export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
async function mode(value:string){await run(async()=>{if(value==='Live'&&!window.confirm('Enable automatic FAQ sending for new incoming messages? Confirm that you reviewed test-mode results and accepted Gmail delivery with a sandbox mailbox. Up to 20 replies per hotel per UTC day may be sent.'))return;onHotel(await api<Hotel>('/auto-replies/mode','PUT',{mode:value,version:hotel.version,acceptanceConfirmed:value==='Live'}));});}
|
||||
async function save(e:React.FormEvent){e.preventDefault();await run(async()=>{const item=await api<Rule>(`/auto-replies/rules/${question}`,'PUT',{question:status!.questions[question],knowledgeId:answer,enabled,version:current?.version||0});setRules(old=>[item,...old.filter(r=>r.id!==item.id)]);setResult(null);});}
|
||||
return <div className="page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">Simple questions, thoughtful answers</span><h1>FAQ automation</h1><p>Start in test mode. Let approved answers handle a small set of straightforward questions.</p></div><button className="button secondary" disabled={busy} onClick={()=>run(refresh)}>Refresh results</button></div>
|
||||
<section className="settings-card"><h2>Automation mode: {hotel.autoReplyMode||'Off'}</h2><p>Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.</p><div className="form-actions"><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Off')}>Turn off</button><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Test')}>Use test mode</button><button className="button primary" disabled={busy||!owner||!status?.liveConfigured||!hotel.staffSendingEnabled} onClick={()=>mode('Live')}>Enable live replies</button></div><p className="small muted">Test mode sends nothing. Live mode requires administrator enablement and Gmail sending. Each mode change starts with new incoming messages; existing inbox messages are not sent automatically.</p><p className="small muted">Maximum 20 automatic replies per hotel per UTC day, one per sender per UTC day, and one per Gmail thread. Turning off stops queued replies when the worker next checks them; a request already submitted to Gmail cannot be recalled.</p>{status?.preview&&<p className="staff-note">Sample workspace: the question tester works here. Live sending is disabled.</p>}</section>
|
||||
<section className="settings-card"><h2>Automation mode: {hotel.autoReplyMode||'Off'}</h2><p>Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.</p><div className="form-actions"><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Off')}>Turn off</button><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Test')}>Use test mode</button><button className="button primary" disabled={busy||!canLive||!status?.liveConfigured||!hotel.staffSendingEnabled} onClick={()=>mode('Live')}>Enable live replies</button></div><p className="small muted">Test mode sends nothing. Live mode requires administrator enablement and Gmail sending. Each mode change starts with new incoming messages; existing inbox messages are not sent automatically.</p><p className="small muted">Maximum 20 automatic replies per hotel per UTC day, one per sender per UTC day, and one per Gmail thread. Turning off stops queued replies when the worker next checks them; a request already submitted to Gmail cannot be recalled.</p>{status?.preview&&<p className="staff-note">Sample workspace: the question tester works here. Live sending is disabled.</p>}</section>
|
||||
<div className="pms-columns"><section className="settings-card"><h2>Review a FAQ rule</h2><form onSubmit={save}><fieldset disabled={busy||!owner}><label>Complete guest question<select value={question} onChange={e=>setQuestion(Number(e.target.value))}>{status?.questions.map((q,i)=><option value={i} key={q}>{q}</option>)}</select></label><label>Approved hotel answer<select value={answer} required onChange={e=>setAnswer(e.target.value)}><option value="">Choose an answer</option>{knowledge.filter(k=>k.approved).map(k=><option value={k.id} key={k.id}>{k.title}</option>)}</select></label>{answer&&<p className="staff-note">{knowledge.find(k=>k.id===answer)?.answer}</p>}<label className="checkbox-label"><input type="checkbox" checked={enabled} onChange={e=>setEnabled(e.target.checked)}/>Enable this exact question and reviewed answer</label><button className="button secondary">Save reviewed rule</button></fieldset></form><p className="small muted">The approved answer is sent exactly as saved, with the hotel signature. Editing the answer pauses matching until this rule is reviewed and saved again.</p></section>
|
||||
<section className="settings-card"><h2>Try a question</h2><form onSubmit={e=>{e.preventDefault();run(async()=>setResult(await api<Decision>('/auto-replies/test','POST',{subject,body})));}}><fieldset disabled={busy||!owner}><label>Subject<input value={subject} maxLength={200} onChange={e=>setSubject(e.target.value)}/></label><label>Complete message<textarea rows={4} value={body} maxLength={2000} required onChange={e=>setBody(e.target.value)}/></label><button className="button secondary">Check match without sending</button></fieldset></form>{result&&<div role="status" className="staff-note"><strong>{result.matches?'Content matches a reviewed rule':'Keep with staff'}</strong><p>{result.reason}</p>{result.body&&<p>{result.body}</p>}</div>}<p className="small muted">This tester checks content only. Real messages must also pass sender, recipient, age, thread and delivery-limit checks.</p></section></div>
|
||||
<section className="settings-card"><h2>Recent incoming-message results</h2>{history.length===0?<p>No incoming messages have been evaluated yet. Enable test mode after connecting your mailbox.</p>:<div className="pms-history">{history.map(h=><div className="pms-history-item" key={h.id}><strong>{h.subject}</strong><span>{h.autoReplyDetail}</span><span>{h.delivery?`Delivery: ${h.delivery} · `:''}{hotelTime(h.autoReplyCheckedAt,hotel.timezone)}</span></div>)}</div>}</section>
|
||||
|
||||
@ -3,8 +3,8 @@ import { api, type Hotel } from './api';
|
||||
import { hotelTime } from './time';
|
||||
type Payment={id:string;reference:string;email:string;description:string;amount:number;currency:string;state:string;detail:string;invoiceId:string;version:number;expiresAt:string;updatedAt:string;checkedAt:string|null};
|
||||
type Connection={configured:boolean;createsConfigured:boolean;sandbox:boolean;preview:boolean};
|
||||
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
type Props={hotel:Hotel;owner:boolean;canPropose:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
export function PaymentsPage({hotel,owner,canPropose,busy,run,onHotel}:Props){
|
||||
const [connection,setConnection]=useState<Connection|null>(null),[items,setItems]=useState<Payment[]>([]),[selected,setSelected]=useState<string|null>(null),[approved,setApproved]=useState(false);
|
||||
const [reference,setReference]=useState(''),[email,setEmail]=useState(''),[description,setDescription]=useState(''),[amount,setAmount]=useState(''),[currency,setCurrency]=useState('GBP');
|
||||
async function refresh(){const [c,p]=await Promise.all([api<Connection>('/payments/status'),api<Payment[]>('/payments/requests')]);setConnection(c);setItems(p);}
|
||||
@ -18,7 +18,7 @@ export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
});}
|
||||
return <div className="page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">A clear request, a clear record</span><h1>Payments</h1><p>Prepare a hosted invoice, review it, and check its status with NMI.</p></div><button className="button secondary" disabled={busy} onClick={()=>run(refresh)}>Refresh history</button></div>
|
||||
<section className="settings-card"><h2>Payment connection</h2><p>{connection?.preview?'Sample workspace: no invoices, emails or payments can be created.':connection?.configured?`NMI ${connection.sandbox?'sandbox':'production'} configuration is available.`:'Your administrator needs to configure this hotel’s NMI merchant account.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.paymentsEnabled||false} disabled={busy||!owner||(!connection?.createsConfigured&&!hotel.paymentsEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable reviewed NMI invoice creation after sandbox acceptance? Creating an invoice may email the customer.'))return;onHotel(await api<Hotel>('/payments/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved payment invoices</label><p className="small muted">Card details are entered on NMI’s hosted page. GuestOps never asks for a card number.</p></section>
|
||||
<div className="pms-columns"><section className="settings-card"><h2>Prepare a payment request</h2><form onSubmit={propose}><fieldset disabled={busy||!owner||!connection?.configured}><label>Unique payment reference<input value={reference} onChange={e=>setReference(e.target.value)} pattern="[A-Za-z0-9-]+" maxLength={80} required placeholder="WH-2481-DEPOSIT"/></label><label>Customer email<input type="email" value={email} onChange={e=>setEmail(e.target.value)} maxLength={254} required/></label><label>Description<input value={description} onChange={e=>setDescription(e.target.value)} maxLength={250} required placeholder="Deposit for reservation WH-2481"/></label><div className="form-grid"><label>Amount<input type="number" min="0.01" max="100000" step="0.01" required value={amount} onChange={e=>setAmount(e.target.value)}/></label><label>Currency<select value={currency} onChange={e=>setCurrency(e.target.value)}><option>GBP</option><option>EUR</option><option>USD</option></select></label></div><button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.</p></section>
|
||||
<div className="pms-columns"><section className="settings-card"><h2>Prepare a payment request</h2><form onSubmit={propose}><fieldset disabled={busy||!canPropose||!connection?.configured}><label>Unique payment reference<input value={reference} onChange={e=>setReference(e.target.value)} pattern="[A-Za-z0-9-]+" maxLength={80} required placeholder="WH-2481-DEPOSIT"/></label><label>Customer email<input type="email" value={email} onChange={e=>setEmail(e.target.value)} maxLength={254} required/></label><label>Description<input value={description} onChange={e=>setDescription(e.target.value)} maxLength={250} required placeholder="Deposit for reservation WH-2481"/></label><div className="form-grid"><label>Amount<input type="number" min="0.01" max="100000" step="0.01" required value={amount} onChange={e=>setAmount(e.target.value)}/></label><label>Currency<select value={currency} onChange={e=>setCurrency(e.target.value)}><option>GBP</option><option>EUR</option><option>USD</option></select></label></div><button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.</p></section>
|
||||
<section className="settings-card"><h2>Payment history</h2>{items.length===0&&<p>No payment requests yet.</p>}<div className="pms-history">{items.map(p=><button key={p.id} className={'pms-history-item '+(selected===p.id?'selected':'')} onClick={()=>{setSelected(p.id);setApproved(false);}}><strong>{p.reference} · {p.currency} {p.amount.toFixed(2)}</strong><span>{p.state==='NeedsReview'?'Needs verification':p.state==='Paid'?'Paid · reported by NMI':p.state} · {p.email}</span></button>)}</div></section></div>
|
||||
{current&&<section className="settings-card" aria-label="Payment request review"><h2>{current.state==='Review'?'Review this payment request':'Payment request status'}</h2><dl className="pms-reservation"><div><dt>Reference</dt><dd>{current.reference}</dd></div><div><dt>Customer</dt><dd>{current.email}</dd></div><div><dt>Amount</dt><dd>{current.currency} {current.amount.toFixed(2)}</dd></div><div><dt>Description</dt><dd>{current.description}</dd></div><div><dt>NMI invoice</dt><dd>{current.invoiceId||'Not confirmed'}</dd></div><div><dt>Last verified</dt><dd>{current.checkedAt?hotelTime(current.checkedAt,hotel.timezone):'Not yet verified'}</dd></div></dl><p role="status"><strong>{current.state==='Paid'?'Paid · reported by NMI':current.state}</strong> — {current.detail}</p>
|
||||
{current.state==='Review'?<><div className="staff-note">Creating the invoice may email this customer a hosted payment link through NMI. GuestOps records the invoice and its status.</div><label className="checkbox-label"><input type="checkbox" checked={approved} onChange={e=>setApproved(e.target.checked)}/>I checked the recipient, amount and currency, and approve NMI emailing this payment request.</label><div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||connection?.preview||!hotel.paymentsEnabled||!connection?.createsConfigured||!approved||Date.now()>new Date(current.expiresAt).getTime()} onClick={()=>action('create')}>Approve and create invoice</button></div><p className="small muted">Approval expires after fifteen minutes. A payment reference cannot be reused.</p></>:!['Cancelled','NotCreated'].includes(current.state)&&<><button className="button secondary" disabled={busy||!owner||connection?.preview||(current.state==='Creating'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('check')}>Verify with NMI</button><p className="small muted">This only reads invoice status. An interrupted creation can be checked after five minutes. Uncertain invoices are never automatically recreated.</p></>}
|
||||
|
||||
@ -4,8 +4,8 @@ import { hotelTime } from './time';
|
||||
type Snapshot={id:string;reservationId:string;confirmation:string;guestName:string;arrival:string;departure:string;status:string;roomType:string;total:string;fetchedAt:string};
|
||||
type Change={id:string;reservationId:string;kind:string;arrival:string;departure:string;note:string;state:string;detail:string;version:number;updatedAt:string;expiresAt:string;before:Snapshot;after:Snapshot|null};
|
||||
type Connection={configured:boolean;writesConfigured:boolean;hotelCode:string;preview:boolean};
|
||||
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
export function PmsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
type Props={hotel:Hotel;owner:boolean;canPropose:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
export function PmsPage({hotel,owner,canPropose,busy,run,onHotel}:Props){
|
||||
const [connection,setConnection]=useState<Connection|null>(null),[changes,setChanges]=useState<Change[]>([]),[confirmation,setConfirmation]=useState(''),[snapshot,setSnapshot]=useState<Snapshot|null>(null);
|
||||
const [kind,setKind]=useState('AddNote'),[arrival,setArrival]=useState(''),[departure,setDeparture]=useState(''),[note,setNote]=useState(''),[selected,setSelected]=useState<string|null>(null),[checked,setChecked]=useState(false);
|
||||
async function refresh(){const [status,history]=await Promise.all([api<Connection>('/pms/status'),api<Change[]>('/pms/changes')]);setConnection(status);setChanges(history);}
|
||||
@ -21,7 +21,7 @@ export function PmsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
return <div className="page pms-page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">Booking details, close at hand</span><h1>Reservations</h1><p>Look up a booking and review changes before applying them to your PMS.</p></div><button className="button secondary" disabled={busy} onClick={()=>run(refresh)}>Refresh status</button></div>
|
||||
<section className="settings-card"><h2>OHIP connection</h2><p>{connection?.configured?`Connected configuration for property ${connection.hotelCode}. Lookup will verify access.`:connection?.preview?'Sample workspace: real PMS lookup and updates are disabled.':'Your administrator needs to configure this hotel’s OHIP connection.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.pmsUpdatesEnabled||false} disabled={busy||!owner||(!connection?.writesConfigured&&!hotel.pmsUpdatesEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable staff-approved PMS updates for this hotel? Only enable this after the configured OHIP sandbox has passed acceptance checks.'))return;onHotel(await api<Hotel>('/pms/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved PMS updates</label><p className="small muted">Lookup is available separately. Every change needs review; automatic PMS updates are off.</p></section>
|
||||
<div className="pms-columns"><section className="settings-card"><h2>Find a reservation</h2><form onSubmit={lookup}><label>Exact confirmation number<input value={confirmation} maxLength={80} pattern="[a-zA-Z0-9-]+" required onChange={e=>setConfirmation(e.target.value)} placeholder="For example, 12345678"/></label><button className="button primary" disabled={busy||!connection?.configured}>Look up reservation</button></form>
|
||||
{snapshot&&<><Reservation value={snapshot}/><form onSubmit={propose}><fieldset disabled={busy||!owner}><label>Proposed action<select value={kind} onChange={e=>setKind(e.target.value)}><option value="AddNote">Add an internal reservation note</option><option value="StayDates">Change stay dates</option></select></label>{kind==='AddNote'?<label>Note<textarea rows={4} maxLength={2000} value={note} required onChange={e=>setNote(e.target.value)}/></label>:<div className="form-grid"><label>New arrival<input type="date" value={arrival} required onChange={e=>setArrival(e.target.value)}/></label><label>New departure<input type="date" value={departure} required onChange={e=>setDeparture(e.target.value)}/></label></div>}<button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparing a proposal does not update the PMS. Lookup and proposals expire after ten minutes.</p></>}
|
||||
{snapshot&&<><Reservation value={snapshot}/><form onSubmit={propose}><fieldset disabled={busy||!canPropose}><label>Proposed action<select value={kind} onChange={e=>setKind(e.target.value)}><option value="AddNote">Add an internal reservation note</option><option value="StayDates">Change stay dates</option></select></label>{kind==='AddNote'?<label>Note<textarea rows={4} maxLength={2000} value={note} required onChange={e=>setNote(e.target.value)}/></label>:<div className="form-grid"><label>New arrival<input type="date" value={arrival} required onChange={e=>setArrival(e.target.value)}/></label><label>New departure<input type="date" value={departure} required onChange={e=>setDeparture(e.target.value)}/></label></div>}<button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparing a proposal does not update the PMS. Lookup and proposals expire after ten minutes.</p></>}
|
||||
</section><section className="settings-card"><h2>Change history</h2>{!changes.length&&<p>No PMS changes have been prepared yet.</p>}<div className="pms-history">{changes.map(c=><button className={'pms-history-item '+(selected===c.id?'selected':'')} key={c.id} onClick={()=>{setSelected(c.id);setChecked(false);}}><strong>{c.before.confirmation} · {c.kind==='AddNote'?'Reservation note':'Stay dates'}</strong><span>{c.state==='NeedsReview'?'Needs verification':c.state} · {hotelTime(c.updatedAt,hotel.timezone)}</span></button>)}</div></section></div>
|
||||
{current&&<section className="settings-card pms-review" aria-label="PMS change review"><h2>{current.state==='Review'?'Review this PMS change':'PMS change status'}</h2><Reservation value={current.before}/><div className="staff-note">{current.kind==='StayDates'?`Requested stay: ${current.arrival} to ${current.departure}`:`Add internal note: ${current.note}`}</div><p role="status"><strong>{current.state==='NeedsReview'?'Needs verification':current.state}</strong> — {current.detail}</p>{current.after&&<><h3>Latest observed PMS state</h3><Reservation value={current.after}/></>}{current.state==='Review'&&<>{current.kind==='StayDates'&&<label className="checkbox-label"><input type="checkbox" checked={checked} onChange={e=>setChecked(e.target.checked)}/>I checked availability, rate consequences and guest agreement in the PMS. GuestOps does not quote or guarantee a new price here.</label>}<div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||!hotel.pmsUpdatesEnabled||!connection?.writesConfigured||(current.kind==='StayDates'&&!checked)||new Date(current.expiresAt)<new Date()} onClick={()=>action('apply')}>Approve and apply to PMS</button></div></>}{(current.state==='NeedsReview'||current.state==='Applying')&&<><button className="button secondary" disabled={busy||!owner||(current.state==='Applying'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('verify')}>Verify current PMS state</button><p className="small muted">An interrupted update can be checked after five minutes. Verification only reads the PMS; it never repeats the update.</p></>}<p className="small muted">Operation reference: {current.id}</p></section>}
|
||||
</div>;
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { hotelTime } from './time';
|
||||
type Member={id:string;name:string;email:string;role:string;active:boolean;pending:boolean;version:number;linkPurpose:string;linkExpiresAt:string|null};
|
||||
type Link={userId:string;link:string;expiresAt:string};
|
||||
export function TeamPage({owner,timeZone}:{owner:boolean;timeZone:string}) {
|
||||
const [members,setMembers]=useState<Member[]>([]),[name,setName]=useState(''),[email,setEmail]=useState(''),[error,setError]=useState(''),[busy,setBusy]=useState(false),[link,setLink]=useState<Link|null>(null),[copied,setCopied]=useState(false);
|
||||
type Member={id:string;name:string;email:string;role:string;legacyRole:boolean;active:boolean;pending:boolean;mfaEnabled:boolean;version:number;linkPurpose:string;linkExpiresAt:string|null};
|
||||
type Link={userId:string;link?:string;deliveryState:string;expiresAt:string};
|
||||
export function TeamPage({owner,actorRole,timeZone}:{owner:boolean;actorRole:string;timeZone:string}) {
|
||||
const [members,setMembers]=useState<Member[]>([]),[name,setName]=useState(''),[email,setEmail]=useState(''),[role,setRole]=useState('Agent'),[error,setError]=useState(''),[busy,setBusy]=useState(false),[link,setLink]=useState<Link|null>(null),[copied,setCopied]=useState(false);
|
||||
async function refresh(){setMembers(await api<Member[]>('/team'));}
|
||||
useEffect(()=>{if(owner)refresh().catch(e=>setError(e.message));},[owner]);
|
||||
async function act(path:string,body:unknown){if(busy)return;setBusy(true);setError('');setLink(null);setCopied(false);try{const result=await api<Link|null>(path,'POST',body);if(result?.link)setLink(result);await refresh();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
|
||||
async function act(path:string,body:unknown,method='POST'){if(busy)return;setBusy(true);setError('');setLink(null);setCopied(false);try{const result=await api<Link|null>(path,method,body);if(result?.deliveryState)setLink(result);await refresh();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
|
||||
if(!owner)return <div className="page"><h1>Team access</h1><p>Your hotel owner manages staff accounts.</p></div>;
|
||||
return <div className="page settings-page"><div className="page-heading"><span className="eyebrow">A place for everyone</span><h1>Your team</h1><p>Give each colleague their own access to the hotel workspace.</p></div>{error&&<div className="alert" role="alert">{error}</div>}
|
||||
{link&&<section className="settings-card account-link" aria-label="Private account link"><h2>Share this link privately</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>No email has been sent. Verify the colleague's identity and share only with the intended account holder. Anyone with this link can set their password.</p><p>Expires {hotelTime(link.expiresAt,timeZone)}. The link is shown here once.</p><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link);setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></section>}
|
||||
<section className="settings-card"><h2>Invite a colleague</h2><p className="muted">Staff can work on guest conversations. Owners manage hotel settings, integrations and approvals.</p><form onSubmit={e=>{e.preventDefault();void act('/team/invite',{name,email});}}><fieldset disabled={busy}><div className="form-grid"><label>Full name<input required minLength={2} maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>Work email<input type="email" required maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label></div><div className="form-actions"><button className="button primary">Create invitation link</button></div></fieldset></form></section>
|
||||
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=><article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {hotelTime(m.linkExpiresAt!,timeZone)}</p>}</div>{m.role==='Staff'&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>)}</div><p className="small muted">New links replace earlier links. Password recovery ends existing sessions once accepted. Owner recovery is handled by your server administrator.</p></section>
|
||||
{link&&<section className="settings-card account-link" aria-label="Account delivery"><h2>{link.link?'Share this preview link privately':'Account email queued'}</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>Delivery state: {link.deliveryState}. Expires {hotelTime(link.expiresAt,timeZone)}.</p>{link.link&&<><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link||'');setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></>}</section>}
|
||||
<section className="settings-card"><h2>Invite a colleague</h2><p className="muted">Roles are enforced by the server. Managers may manage Agent and Auditor accounts only.</p><form onSubmit={e=>{e.preventDefault();void act('/team/invite',{name,email,role});}}><fieldset disabled={busy}><div className="form-grid"><label>Full name<input required minLength={2} maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>Work email<input type="email" required maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label><label>Role<select value={role} onChange={e=>setRole(e.target.value)}>{actorRole==='Owner'&&<option>Manager</option>}<option>Agent</option><option>Auditor</option></select></label></div><div className="form-actions"><button className="button primary">Send invitation</button></div></fieldset></form></section>
|
||||
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=>{const manageable=m.role!=='Owner'&&(actorRole==='Owner'||m.role==='Agent'||m.role==='Auditor');return <article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role}{m.legacyRole?' (legacy Staff)':''} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'} · MFA {m.mfaEnabled?'on':'off'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {hotelTime(m.linkExpiresAt!,timeZone)}</p>}</div>{manageable&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email,role:m.role})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.active&&<select aria-label={`Role for ${m.name}`} value={m.role} onChange={e=>act(`/team/${m.id}/role`,{version:m.version,role:e.target.value},'PUT')}>{actorRole==='Owner'&&<option>Manager</option>}<option>Agent</option><option>Auditor</option></select>}{actorRole==='Owner'&&m.mfaEnabled&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Reset MFA for ${m.name} after completing identity verification?`))void act(`/team/${m.id}/mfa-reset`,{version:m.version});}}>Reset MFA</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>;})}</div><p className="small muted">New links invalidate earlier links. Password, role, disablement and MFA changes invalidate affected sessions. Owner MFA recovery is available only through the audited server command.</p></section>
|
||||
</div>;
|
||||
}
|
||||
type Setup={preview:boolean;steps:{title:string;detail:string;path:string;complete:boolean;optional:boolean}[]};
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
export type User = { id: string; name: string; role: string; hotelId: string };
|
||||
export type Preferences = { displayTimeZone: string; effectiveDisplayTimeZone: string; defaultInboxFilter: string; version: number };
|
||||
export type User = { id: string; name: string; role: string; hotelId: string; permissions: string[]; mfaEnabled: boolean; preferences: Preferences };
|
||||
export type Session = { preview: boolean; csrfToken: string; user: User | null };
|
||||
export type LoginResult = { mfaRequired: boolean; enrollmentRequired?: boolean };
|
||||
export type Hotel = { id: string; name: string; timezone: string; signature: string; replyMode: string; version: number; aiDraftsEnabled: boolean; staffSendingEnabled: boolean; autoReplyMode: string; paymentsEnabled: boolean; pmsUpdatesEnabled: boolean };
|
||||
export type Conversation = { id: string; from: string; subject: string; body: string; receivedAt: string; status: string; draft: string; category: string; note: string; providerThreadId: string; version: number; mailboxId: string; autoReplyDetail: string; replyAddress: string; draftSources: string[]; draftReviewNote: string; delivery: { automatic: boolean; state: string; recipient: string; body: string; detail: string; messageId: string; providerId: string } | null };
|
||||
export type ConversationPage = { items: Conversation[]; nextCursor: string | null };
|
||||
@ -12,7 +14,7 @@ export async function api<T>(path: string, method = 'GET', body?: unknown): Prom
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && path !== '/auth/login') window.dispatchEvent(new Event('session-expired'));
|
||||
throw new Error(data?.error || (response.status === 429 ? 'Too many attempts. Please wait a minute.' : response.status === 403 ? 'Only the hotel owner can change this.' : 'We couldn’t complete that request. Please try again.'));
|
||||
throw new Error(data?.error || (response.status === 429 ? 'Too many attempts. Please wait a minute.' : response.status === 403 ? 'Your role does not allow this action.' : 'We couldn’t complete that request. Please try again.'));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Banknote, Building2, ChevronRight } from 'lucide-react';
|
||||
import { api, session, type Session, type Hotel, type Conversation, type ConversationPage, type Knowledge, type Activity, type Mailboxes } from './api';
|
||||
import { api, session, type Session, type LoginResult, type Hotel, type Conversation, type ConversationPage, type Knowledge, type Activity, type Mailboxes } from './api';
|
||||
import './style.css';
|
||||
import { OperationsPage } from './OperationsPage';
|
||||
import { MailboxPanel } from './MailboxPanel';
|
||||
@ -24,48 +24,60 @@ function App() {
|
||||
const [conversationCursor,setConversationCursor]=useState<string|null>(null);
|
||||
const [loaded,setLoaded] = useState(false);
|
||||
async function refresh() {
|
||||
const [h,c,k,a,m] = await Promise.all([api<Hotel>('/hotel'),api<ConversationPage>('/conversations/page'),api<Knowledge[]>('/knowledge'),api<Activity[]>('/activity'),api<Mailboxes>('/mailboxes')]);
|
||||
setHotel(h);setConversations(c.items);setConversationCursor(c.nextCursor);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true);
|
||||
const user=auth?.user;if(!user)return;const inbox=user.permissions.includes('Inbox'),audit=user.permissions.includes('Audit');
|
||||
const [h,c,k,a,m]=await Promise.all([inbox?api<Hotel>('/hotel'):Promise.resolve(null),inbox?api<ConversationPage>('/conversations/page'):Promise.resolve(null),inbox?api<Knowledge[]>('/knowledge'):Promise.resolve([]),audit?api<Activity[]>('/activity'):Promise.resolve([]),inbox?api<Mailboxes>('/mailboxes'):Promise.resolve({configured:false,items:[]} as Mailboxes)]);
|
||||
setHotel(h||{id:user.hotelId,name:'Hotel workspace',timezone:user.preferences.effectiveDisplayTimeZone,signature:'',replyMode:'DraftOnly',version:0,aiDraftsEnabled:false,staffSendingEnabled:false,autoReplyMode:'Off',paymentsEnabled:false,pmsUpdatesEnabled:false});
|
||||
setConversations(c?.items||[]);setConversationCursor(c?.nextCursor||null);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true);
|
||||
}
|
||||
async function moreConversations(){if(!conversationCursor)return;const page=await api<ConversationPage>('/conversations/page?cursor='+encodeURIComponent(conversationCursor));setConversations(old=>[...old,...page.items.filter(item=>!old.some(existing=>existing.id===item.id))]);setConversationCursor(page.nextCursor);}
|
||||
useEffect(()=>{pageRef.current=page;},[page]);
|
||||
useEffect(() => { session().then(setAuth).catch(e=>setError(e.message)); const expired=()=>{setAuth(null);session().then(setAuth).catch(()=>{});setError('Your session has ended. Sign in again.');}; window.addEventListener('session-expired',expired); const pop=()=>{if(!window.dispatchEvent(new Event('workspace-navigate',{cancelable:true}))){history.pushState({},'',pageRef.current);return;}setPage(location.pathname==='/'?'/inbox':location.pathname);};window.addEventListener('popstate',pop);return()=>{window.removeEventListener('session-expired',expired);window.removeEventListener('popstate',pop);}; },[]);
|
||||
useEffect(()=>{if(auth?.user) refresh().catch(e=>setError(e.message));},[auth?.user?.id]);
|
||||
useEffect(()=>{if(auth?.user&&!auth.user.permissions.includes('Inbox')&&['/','/inbox','/reservations','/payments','/automation','/knowledge','/settings'].includes(page))setPage(auth.user.permissions.includes('Audit')?'/activity':'/health');},[auth?.user?.id,page]);
|
||||
useEffect(()=>{if(!notice)return;const timer=setTimeout(()=>setNotice(''),4500);return()=>clearTimeout(timer);},[notice]);
|
||||
async function run(action:()=>Promise<void>) { if(busy)return; setBusy(true);setError('');try{await action();}catch(e){setError(e instanceof Error?e.message:'Something went wrong.');}finally{setBusy(false);} }
|
||||
function go(path:string) { if (!window.dispatchEvent(new Event('workspace-navigate', { cancelable:true }))) return; setPage(path);history.pushState({},'',path);setError(''); }
|
||||
async function login(email:string,password:string) { await run(async()=>{await api('/auth/login','POST',{email,password});setAuth(await session());}); }
|
||||
async function login(email:string,password:string) { let result:LoginResult={mfaRequired:false};await run(async()=>{result=await api<LoginResult>('/auth/login','POST',{email,password});if(!result.mfaRequired)setAuth(await session());});return result; }
|
||||
async function preview() { await run(async()=>{await api('/preview/start','POST');setAuth(await session());}); }
|
||||
async function logout() { await run(async()=>{await api('/auth/logout','POST');setAuth(await session());setHotel(null);setLoaded(false);}); }
|
||||
const errorBox=error?<div className="alert" role="alert"><CircleHelp size={18}/><span>{error}</span><button className="icon-button" onClick={()=>setError('')} aria-label="Dismiss error"><X size={17}/></button></div>:null;
|
||||
if(!auth) return <div className="loading"><span className="brand-mark">g</span><p>Opening your workspace…</p>{errorBox}</div>;
|
||||
if(page==="/account") return <AccountPage/>;
|
||||
if(!auth.user) return <Login preview={auth.preview} onLogin={login} onPreview={preview} busy={busy} error={errorBox}/>;
|
||||
const count=conversations.filter(c=>c.status!=='Completed').length;
|
||||
if(!auth.user) return <Login preview={auth.preview} onLogin={login} onAuthenticated={async()=>setAuth(await session())} onPreview={preview} busy={busy} error={errorBox}/>;
|
||||
const count=conversations.filter(c=>c.status!=='Completed').length,can=(permission:string)=>auth.user!.permissions.includes(permission);
|
||||
return <div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<a href="/inbox" aria-label="GuestOps inbox" className="brand" onClick={e=>{e.preventDefault();go('/inbox');}}><span className="brand-mark">g</span><span>guestops<span className="brand-dot">.</span></span></a>
|
||||
<div className="hotel-switch"><span className="hotel-icon"><Building2 size={19}/></span><div><strong>{hotel?.name||'Your hotel'}</strong><small>Hotel workspace</small></div></div>
|
||||
<div className="nav-label">WORKSPACE</div>
|
||||
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox},{path:'/reservations',label:'Reservations',icon:Building2},{path:'/payments',label:'Payments',icon:Banknote},{path:'/automation',label:'FAQ automation',icon:ShieldCheck},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen},{path:'/activity',label:'Activity',icon:ActivityIcon},{path:'/settings',label:'Settings',icon:Settings},{path:'/team',label:'Your team',icon:ShieldCheck},{path:'/setup',label:'Hotel setup',icon:CheckCheck},{path:'/health',label:'Workspace health',icon:ActivityIcon}].filter(n=>auth.user?.role==='Owner'||!['/team','/setup','/health'].includes(n.path)).map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
|
||||
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox,permission:'Inbox'},{path:'/reservations',label:'Reservations',icon:Building2,permission:'ProviderLookup'},{path:'/payments',label:'Payments',icon:Banknote,permission:'ProviderLookup'},{path:'/automation',label:'FAQ automation',icon:ShieldCheck,permission:'AutomationTest'},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen,permission:'Knowledge'},{path:'/activity',label:'Activity',icon:ActivityIcon,permission:'Audit'},{path:'/settings',label:'Settings',icon:Settings,permission:'HotelSettings'},{path:'/team',label:'Your team',icon:ShieldCheck,permission:'Team'},{path:'/setup',label:'Hotel setup',icon:CheckCheck,permission:'HotelSettings'},{path:'/health',label:'Workspace health',icon:ActivityIcon,permission:'Operations'},{path:'/profile',label:'My account',icon:ShieldCheck,permission:''}].filter(n=>!n.permission||can(n.permission)).map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
|
||||
<div className="sidebar-bottom"><div className="mode-card"><ShieldCheck size={20}/><strong>You're in control</strong><p>Your team controls approved answers and reply automation.</p></div><div className="profile"><span className="avatar profile-avatar">{initials(auth.user.name)}</span><div><strong>{auth.user.name}</strong><small>{auth.user.role==='Owner'?'Hotel owner':'Team member'}</small></div><button className="icon-button" onClick={logout} disabled={busy} aria-label="Sign out"><LogOut size={18}/></button></div></div>
|
||||
</aside>
|
||||
<main className="main">
|
||||
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':page==='/automation'?'FAQ automation':page==='/team'?'Your team':page==='/setup'?'Hotel setup':page==='/health'?'Workspace health':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
|
||||
{errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={auth.user.role==='Owner'} go={go}/>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'} timeZone={hotel!.timezone}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at,hotel!.timezone)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={can('Operations')} go={go}/>:page==='/team'?<TeamPage owner={can('Team')} actorRole={auth.user.role} timeZone={hotel!.timezone}/>:page==='/setup'?<OnboardingPage owner={can('HotelSettings')} go={go}/>:page==='/profile'?<PreferencesPage value={auth.user.preferences} mfaEnabled={auth.user.mfaEnabled} onSaved={async()=>setAuth(await session())}/>:page==='/inbox'?<InboxPage hotel={hotel!} defaultFilter={auth.user.preferences.defaultInboxFilter} displayTimeZone={auth.user.preferences.effectiveDisplayTimeZone} mailboxes={mailboxes} conversations={conversations} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={can('AutomationTest')} canLive={can('AutomationLive')} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={can('ExternalApproval')} canPropose={can('ProviderProposal')} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={can('ExternalApproval')} canPropose={can('ProviderProposal')} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={can('Knowledge')} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at,auth.user!.preferences.effectiveDisplayTimeZone)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={can('HotelSettings')} integrations={can('Integrations')} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
function PageHeading({eyebrow,title,text}:{eyebrow:string;title:string;text:string}) { return <div className="page-heading"><span className="eyebrow">{eyebrow}</span><h1>{title}</h1><p>{text}</p></div>; }
|
||||
function Empty({title,text}:{title:string;text:string}) {return <div className="empty"><Mail size={34}/><h2>{title}</h2><p>{text}</p></div>;}
|
||||
function Login({preview,onLogin,onPreview,busy,error}:{preview:boolean;onLogin:(e:string,p:string)=>Promise<void>;onPreview:()=>Promise<void>;busy:boolean;error:React.ReactNode}){
|
||||
const [email,setEmail]=useState(''),[password,setPassword]=useState('');
|
||||
return <div className="login-layout"><section className="login-story"><div className="brand"><span className="brand-mark">g</span>guestops.</div><div><span className="eyebrow">A little more time for your guests</span><h1>Great hospitality.<br/>A calmer inbox.</h1><p>Your conversations, hotel knowledge and team.<br/>Together in one thoughtful workspace.</p><div className="login-detail"><Inbox size={24}/><span>Less time sorting emails.<br/><strong>More time making guests feel welcome.</strong></span></div></div><small>Built around the way hotels work.</small></section><section className="login-form"><div><span className="eyebrow">Your hotel workspace</span><h1>Welcome back</h1><p>Sign in to take care of your guests.</p>{error}<form onSubmit={e=>{e.preventDefault();onLogin(email,password);}}><label>Email address<input type="email" autoComplete="username" value={email} onChange={e=>setEmail(e.target.value)} placeholder="you@yourhotel.com" required/></label><label>Password<input type="password" autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)} required/></label><button className="button primary wide" disabled={busy}>{busy?'Signing in…':'Sign in'}<ArrowUpRight size={18}/></button></form><p className="small muted">Need access or help signing in? Contact your hotel administrator.</p>{preview&&<div className="preview-login"><span>Explore the interface with sample conversations.</span><button className="button secondary wide" onClick={onPreview} disabled={busy}>Open preview workspace<ArrowUpRight size={17}/></button><small>Preview changes are temporary. No real emails are sent.</small></div>}</div></section></div>;
|
||||
function Login({preview,onLogin,onAuthenticated,onPreview,busy,error}:{preview:boolean;onLogin:(e:string,p:string)=>Promise<LoginResult>;onAuthenticated:()=>Promise<void>;onPreview:()=>Promise<void>;busy:boolean;error:React.ReactNode}){
|
||||
const [email,setEmail]=useState(''),[password,setPassword]=useState(''),[stage,setStage]=useState<'password'|'verify'|'enroll'|'codes'>('password'),[code,setCode]=useState(''),[recovery,setRecovery]=useState(false),[secret,setSecret]=useState(''),[uri,setUri]=useState(''),[codes,setCodes]=useState<string[]>([]),[localError,setLocalError]=useState(''),[recovering,setRecovering]=useState(false),[recoverySent,setRecoverySent]=useState(false);
|
||||
async function passwordLogin(e:React.FormEvent){e.preventDefault();setLocalError('');try{const result=await onLogin(email,password);setPassword('');if(result.mfaRequired){if(result.enrollmentRequired){const enrollment=await api<{secret:string;uri:string}>('/auth/mfa/enroll','POST');setSecret(enrollment.secret);setUri(enrollment.uri);setStage('enroll');}else setStage('verify');}}catch(e){setLocalError(e instanceof Error?e.message:'Sign-in failed.');}}
|
||||
async function verify(e:React.FormEvent){e.preventDefault();setLocalError('');try{if(stage==='enroll'){const result=await api<{recoveryCodes:string[]}>('/auth/mfa/enroll/verify','POST',{code});setCodes(result.recoveryCodes);setStage('codes');}else{await api(recovery?'/auth/mfa/recovery-code':'/auth/mfa/verify','POST',{code});await onAuthenticated();}}catch(e){setLocalError(e instanceof Error?e.message:'Verification failed.');}}
|
||||
async function recover(){setLocalError('');try{await api('/auth/recovery','POST',{email});setRecoverySent(true);}catch(e){setLocalError(e instanceof Error?e.message:'Please try again.');}}
|
||||
return <div className="login-layout"><section className="login-story"><div className="brand"><span className="brand-mark">g</span>guestops.</div><div><span className="eyebrow">A little more time for your guests</span><h1>Great hospitality.<br/>A calmer inbox.</h1><p>Your conversations, hotel knowledge and team.<br/>Together in one thoughtful workspace.</p><div className="login-detail"><Inbox size={24}/><span>Less time sorting emails.<br/><strong>More time making guests feel welcome.</strong></span></div></div><small>Built around the way hotels work.</small></section><section className="login-form"><div><span className="eyebrow">Your hotel workspace</span><h1>{stage==='password'?'Welcome back':stage==='enroll'?'Set up your authenticator':stage==='codes'?'Save your recovery codes':'Verify it’s you'}</h1>{error}{localError&&<div className="alert" role="alert">{localError}</div>}{stage==='password'?<><p>Sign in to take care of your guests.</p><form onSubmit={passwordLogin}><label>Email address<input type="email" autoComplete="username" value={email} onChange={e=>setEmail(e.target.value)} placeholder="you@yourhotel.com" required/></label><label>Password<input type="password" autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)} required/></label><button className="button primary wide" disabled={busy}>{busy?'Signing in…':'Sign in'}<ArrowUpRight size={18}/></button></form><button className="button text" onClick={()=>setRecovering(!recovering)}>Forgot your password?</button>{recovering&&<div className="preview-login"><p>Enter your email above. If an active account exists, GuestOps will send a recovery link.</p><button className="button secondary wide" disabled={!email||recoverySent} onClick={recover}>{recoverySent?'Check your email':'Send recovery link'}</button></div>}{preview&&<div className="preview-login"><span>Explore the interface with sample conversations.</span><button className="button secondary wide" onClick={onPreview} disabled={busy}>Open preview workspace<ArrowUpRight size={17}/></button><small>Preview changes are temporary. No real emails are sent.</small></div>}</>:stage==='codes'?<><p>Store these ten single-use codes somewhere secure. They will not be shown again.</p><pre className="staff-note">{codes.join('\n')}</pre><button className="button primary wide" onClick={onAuthenticated}>I saved the codes</button></>:<><p>{stage==='enroll'?<>Add this secret to an authenticator app, then enter its current six-digit code.<br/><strong>{secret}</strong></>:recovery?'Enter one unused recovery code.':'Enter the current six-digit code from your authenticator app.'}</p>{stage==='enroll'&&<details><summary>Authenticator setup URI</summary><code>{uri}</code></details>}<form onSubmit={verify}><label>{recovery?'Recovery code':'Authenticator code'}<input inputMode={recovery?'text':'numeric'} autoComplete="one-time-code" value={code} onChange={e=>setCode(e.target.value)} required/></label><button className="button primary wide">Verify</button></form>{stage==='verify'&&<button className="button text" onClick={()=>{setRecovery(!recovery);setCode('');}}>{recovery?'Use authenticator instead':'Use a recovery code'}</button>}</>}</div></section></div>;
|
||||
}
|
||||
function PreferencesPage({value,mfaEnabled,onSaved}:{value:{displayTimeZone:string;effectiveDisplayTimeZone:string;defaultInboxFilter:string;version:number};mfaEnabled:boolean;onSaved:()=>Promise<void>}){
|
||||
const [form,setForm]=useState(value),[busy,setBusy]=useState(false),[error,setError]=useState(''),[code,setCode]=useState(''),[codes,setCodes]=useState<string[]>([]);
|
||||
async function save(e:React.FormEvent){e.preventDefault();setBusy(true);setError('');try{await api('/me/preferences','PUT',form);await onSaved();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
|
||||
async function regenerate(){setBusy(true);setError('');try{const result=await api<{recoveryCodes:string[]}>('/auth/mfa/recovery-codes','POST',{code});setCodes(result.recoveryCodes);setCode('');await onSaved();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
|
||||
return <div className="page settings-page"><PageHeading eyebrow="Your workspace, your way" title="My account" text="Choose how dates and the inbox appear for you."/>{error&&<div className="alert" role="alert">{error}</div>}<section className="settings-card"><h2>Preferences</h2><form onSubmit={save}><div className="form-grid"><label>Display timezone<select value={form.displayTimeZone} onChange={e=>setForm({...form,displayTimeZone:e.target.value})}><option value="">Use hotel timezone ({form.effectiveDisplayTimeZone})</option>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label><label>Default inbox filter<select value={form.defaultInboxFilter} onChange={e=>setForm({...form,defaultInboxFilter:e.target.value})}>{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts ready'],['Completed','Completed']].map(([key,label])=><option value={key} key={key}>{label}</option>)}</select></label></div><button className="button primary" disabled={busy}>Save preferences</button></form></section><section className="settings-card"><h2>Multi-factor authentication</h2><p>{mfaEnabled?'Your account is protected by an authenticator and recovery codes.':'MFA is not enrolled. When enforcement is enabled, sign-in will guide you through enrollment.'}</p>{mfaEnabled&&<><label>Current authenticator code<input inputMode="numeric" autoComplete="one-time-code" value={code} onChange={e=>setCode(e.target.value)}/></label><button className="button secondary" disabled={busy||code.length!==6} onClick={regenerate}>Generate new recovery codes</button></>}{codes.length>0&&<><p>Store these codes securely. Earlier recovery codes are now invalid.</p><pre className="staff-note">{codes.join('\n')}</pre></>}</section></div>;
|
||||
}
|
||||
type Run=(a:()=>Promise<void>)=>Promise<void>;
|
||||
function InboxPage({hotel,mailboxes,conversations,hasMore,loadMore,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;mailboxes:Mailboxes;conversations:Conversation[];hasMore:boolean;loadMore:()=>void;knowledge:Knowledge[];busy:boolean;run:Run;onUpdate:(c:Conversation)=>void;notify:(s:string)=>void;go:(s:string)=>void}){
|
||||
const [filter,setFilter]=useState('All'),[search,setSearch]=useState(''),[selected,setSelected]=useState<string|null>(null),[draft,setDraft]=useState(''),[mobileDetail,setMobileDetail]=useState(false);
|
||||
function InboxPage({hotel,defaultFilter,displayTimeZone,mailboxes,conversations,hasMore,loadMore,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;defaultFilter:string;displayTimeZone:string;mailboxes:Mailboxes;conversations:Conversation[];hasMore:boolean;loadMore:()=>void;knowledge:Knowledge[];busy:boolean;run:Run;onUpdate:(c:Conversation)=>void;notify:(s:string)=>void;go:(s:string)=>void}){
|
||||
const [filter,setFilter]=useState(defaultFilter),[search,setSearch]=useState(''),[selected,setSelected]=useState<string|null>(null),[draft,setDraft]=useState(''),[mobileDetail,setMobileDetail]=useState(false);
|
||||
const filtered=conversations.filter(c=>(filter==='All'||c.status===filter)&&(c.subject+' '+c.from+' '+c.body).toLowerCase().includes(search.toLowerCase()));
|
||||
const current=filtered.find(c=>c.id===selected)||filtered[0];
|
||||
useEffect(()=>{setDraft(current?.draft||'');},[current?.id,current?.draft]);
|
||||
@ -76,8 +88,8 @@ function InboxPage({hotel,mailboxes,conversations,hasMore,loadMore,knowledge,bus
|
||||
async function resolve() {if(!current)return;await run(async()=>{onUpdate(await api<Conversation>(`/conversations/${current.id}/status`,'PUT',{status:current.status==='Completed'?'NeedsAttention':'Completed',version:current.version}));notify(current.status==='Completed'?'Conversation reopened.':'Conversation marked completed.');});}
|
||||
return <div className="inbox-page"><div className="inbox-heading"><PageHeading eyebrow="A warm welcome starts here" title="Your guest inbox" text={needs?`${needs} conversations need your attention. Let's make their day.`:'A little space to focus on your guests.'}/><div className="mini-stats"><div><strong>{needs}</strong><span>Need attention</span></div><div><strong>{ready}</strong><span>Drafts ready</span></div></div></div>
|
||||
<div className="inbox-toolbar"><div className="tabs" role="group" aria-label="Filter conversations">{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts'],['Completed','Completed']].map(([key,label])=><button key={key} className={filter===key?'tab selected':'tab'} onClick={()=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setFilter(key);}}>{label}{key==='NeedsAttention'&&needs>0&&<span>{needs}</span>}</button>)}</div><label className="search"><Search size={17}/><input aria-label="Search conversations" placeholder="Search loaded messages…" value={search} onChange={e=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setSearch(e.target.value);}}/></label></div>
|
||||
{!conversations.length?<div className="onboard-empty"><Empty title="A calmer inbox starts here" text="Connect your hotel's Google mailbox to bring recent guest conversations into GuestOps."/><button className="button primary" onClick={()=>go('/settings')}>Connect your mailbox<ArrowUpRight size={17}/></button></div>:<div className={'inbox-panels '+(mobileDetail?'show-detail':'')}><section className="message-list" aria-label="Conversations"><div className="list-title">{filtered.length} loaded conversation{filtered.length===1?'':'s'}<span>Newest first<ChevronDown size={13}/></span></div>{filtered.map((c,i)=><button key={c.id} className={'message-card '+(current?.id===c.id?'current':'')} onClick={()=>select(c)}><div className="message-top"><span className={'avatar tone-'+i%4}>{initials(sender(c.from))}</span><strong>{sender(c.from)}</strong><time>{new Date(c.receivedAt).toLocaleTimeString([],{timeZone:hotel.timezone,hour:'2-digit',minute:'2-digit'})}</time></div><h3>{c.subject}</h3><p>{c.body}</p><div className="message-bottom"><span className={'status '+c.status}>{c.status==='DraftReady'?<FileText size={12}/>:c.status==='Completed'?<CheckCheck size={12}/>:<Clock3 size={12}/>} {labels[c.status]}</span><span>{c.category}</span></div></button>)}{hasMore&&<button className="button secondary wide" disabled={busy} onClick={loadMore}>Load 50 older conversations</button>}{!filtered.length&&<Empty title="Nothing here just now" text="Try another filter or search."/>}</section>
|
||||
<section className="conversation" aria-label="Selected conversation">{current?<><div className="conversation-tools"><button className="button text mobile-back" onClick={()=>setMobileDetail(false)}><ArrowLeft size={16}/>Back</button><span className="category-label">{current.category}</span><button className="button secondary compact" onClick={resolve} disabled={busy||!!current.delivery&¤t.delivery.state!=='Sent'}><Check size={16}/>{current.status==='Completed'?'Reopen':'Mark complete'}</button></div><div className="conversation-body"><h2>{current.subject}</h2><div className="sender-line"><span className="avatar tone-0">{initials(sender(current.from))}</span><div><strong>{sender(current.from)}</strong><small>To your hotel · {date(current.receivedAt,hotel.timezone)}</small></div></div><div className="email-body">{current.body}</div>{current.note&&<div className="staff-note"><CircleHelp size={17}/>{current.note}</div>}<div className="reply-box"><div className="reply-header"><span><FileText size={17}/><strong>Your reply draft</strong></span><span className="small muted">Only visible to your team</span></div><label className="sr-only" htmlFor="draft">Reply draft</label><textarea id="draft" disabled={!!current.delivery||busy} value={draft} onChange={e=>setDraft(e.target.value)} placeholder="Write a thoughtful reply…"/><div className="knowledge-insert"><BookOpen size={15}/><select disabled={!!current.delivery||busy} aria-label="Insert an approved hotel answer" value="" onChange={e=>{const k=knowledge.find(x=>x.id===e.target.value);if(k)setDraft(d=>d+(d?'\n\n':'')+k.answer);}}><option value="">Insert an approved hotel answer</option>{knowledge.filter(k=>k.approved).map(k=><option key={k.id} value={k.id}>{k.title}</option>)}</select></div><div className="reply-footer"><span><ShieldCheck size={15}/>No automatic sending</span><button className="button primary" disabled={busy||!!current.delivery||draft===current.draft} onClick={save}><Save size={16}/>Save draft</button></div></div><ReplyActions message={current} hotel={hotel} mailboxes={mailboxes} knowledge={knowledge} dirty={draft!==current.draft} busy={busy} run={run} onUpdate={onUpdate}/><div className="below-draft"><span>Check the reply and recipient before sending.</span>{current.providerThreadId&&/^[a-zA-Z0-9]+$/.test(current.providerThreadId)&&<a href={'https://mail.google.com/mail/u/0/#inbox/'+encodeURIComponent(current.providerThreadId)} target="_blank" rel="noopener noreferrer">Open in Gmail<ArrowUpRight size={14}/></a>}</div></div></>:<Empty title="Choose a conversation" text="Read a message and prepare a reply here."/>}</section></div>}</div>;
|
||||
{!conversations.length?<div className="onboard-empty"><Empty title="A calmer inbox starts here" text="Connect your hotel's Google mailbox to bring recent guest conversations into GuestOps."/><button className="button primary" onClick={()=>go('/settings')}>Connect your mailbox<ArrowUpRight size={17}/></button></div>:<div className={'inbox-panels '+(mobileDetail?'show-detail':'')}><section className="message-list" aria-label="Conversations"><div className="list-title">{filtered.length} loaded conversation{filtered.length===1?'':'s'}<span>Newest first<ChevronDown size={13}/></span></div>{filtered.map((c,i)=><button key={c.id} className={'message-card '+(current?.id===c.id?'current':'')} onClick={()=>select(c)}><div className="message-top"><span className={'avatar tone-'+i%4}>{initials(sender(c.from))}</span><strong>{sender(c.from)}</strong><time>{new Date(c.receivedAt).toLocaleTimeString([],{timeZone:displayTimeZone,hour:'2-digit',minute:'2-digit'})}</time></div><h3>{c.subject}</h3><p>{c.body}</p><div className="message-bottom"><span className={'status '+c.status}>{c.status==='DraftReady'?<FileText size={12}/>:c.status==='Completed'?<CheckCheck size={12}/>:<Clock3 size={12}/>} {labels[c.status]}</span><span>{c.category}</span></div></button>)}{hasMore&&<button className="button secondary wide" disabled={busy} onClick={loadMore}>Load 50 older conversations</button>}{!filtered.length&&<Empty title="Nothing here just now" text="Try another filter or search."/>}</section>
|
||||
<section className="conversation" aria-label="Selected conversation">{current?<><div className="conversation-tools"><button className="button text mobile-back" onClick={()=>setMobileDetail(false)}><ArrowLeft size={16}/>Back</button><span className="category-label">{current.category}</span><button className="button secondary compact" onClick={resolve} disabled={busy||!!current.delivery&¤t.delivery.state!=='Sent'}><Check size={16}/>{current.status==='Completed'?'Reopen':'Mark complete'}</button></div><div className="conversation-body"><h2>{current.subject}</h2><div className="sender-line"><span className="avatar tone-0">{initials(sender(current.from))}</span><div><strong>{sender(current.from)}</strong><small>To your hotel · {date(current.receivedAt,displayTimeZone)}</small></div></div><div className="email-body">{current.body}</div>{current.note&&<div className="staff-note"><CircleHelp size={17}/>{current.note}</div>}<div className="reply-box"><div className="reply-header"><span><FileText size={17}/><strong>Your reply draft</strong></span><span className="small muted">Only visible to your team</span></div><label className="sr-only" htmlFor="draft">Reply draft</label><textarea id="draft" disabled={!!current.delivery||busy} value={draft} onChange={e=>setDraft(e.target.value)} placeholder="Write a thoughtful reply…"/><div className="knowledge-insert"><BookOpen size={15}/><select disabled={!!current.delivery||busy} aria-label="Insert an approved hotel answer" value="" onChange={e=>{const k=knowledge.find(x=>x.id===e.target.value);if(k)setDraft(d=>d+(d?'\n\n':'')+k.answer);}}><option value="">Insert an approved hotel answer</option>{knowledge.filter(k=>k.approved).map(k=><option key={k.id} value={k.id}>{k.title}</option>)}</select></div><div className="reply-footer"><span><ShieldCheck size={15}/>No automatic sending</span><button className="button primary" disabled={busy||!!current.delivery||draft===current.draft} onClick={save}><Save size={16}/>Save draft</button></div></div><ReplyActions message={current} hotel={hotel} mailboxes={mailboxes} knowledge={knowledge} dirty={draft!==current.draft} busy={busy} run={run} onUpdate={onUpdate}/><div className="below-draft"><span>Check the reply and recipient before sending.</span>{current.providerThreadId&&/^[a-zA-Z0-9]+$/.test(current.providerThreadId)&&<a href={'https://mail.google.com/mail/u/0/#inbox/'+encodeURIComponent(current.providerThreadId)} target="_blank" rel="noopener noreferrer">Open in Gmail<ArrowUpRight size={14}/></a>}</div></div></>:<Empty title="Choose a conversation" text="Read a message and prepare a reply here."/>}</section></div>}</div>;
|
||||
}
|
||||
function KnowledgePage({items,canEdit,busy,run,onUpdate,notify}:{items:Knowledge[];canEdit:boolean;busy:boolean;run:Run;onUpdate:(k:Knowledge)=>void;notify:(s:string)=>void}){
|
||||
const [edit,setEdit]=useState<Knowledge|null>(null),[search,setSearch]=useState('');
|
||||
@ -85,10 +97,10 @@ function KnowledgePage({items,canEdit,busy,run,onUpdate,notify}:{items:Knowledge
|
||||
async function save(e:React.FormEvent) {e.preventDefault();if(!edit)return;await run(async()=>{const result=await api<Knowledge>('/knowledge'+(edit.id?'/'+edit.id:''),edit.id?'PUT':'POST',edit);onUpdate(result);setEdit(null);notify('Hotel knowledge saved.');});}
|
||||
return <div className="page"><div className="heading-row"><PageHeading eyebrow="Answers your team can trust" title="Hotel knowledge" text="Keep your policies and helpful answers in one place."/>{canEdit&&<button className="button primary" onClick={()=>setEdit({id:'',title:'',answer:'',keywords:'',category:'General',approved:false,version:0})}><Plus size={17}/>Add an answer</button>}</div><div className="knowledge-summary"><BookOpen size={23}/><div><strong>Your hotel's source of truth</strong><p>Approve answers before your team uses them in a reply. Keep changing details up to date.</p></div><span>{items.filter(x=>x.approved).length} approved</span></div><label className="search knowledge-search"><Search size={17}/><input placeholder="Find an answer…" aria-label="Search hotel knowledge" value={search} onChange={e=>setSearch(e.target.value)}/></label><div className="knowledge-grid">{items.filter(k=>(k.title+' '+k.answer).toLowerCase().includes(search.toLowerCase())).map(k=><article className="knowledge-card" key={k.id}><div><span className="category-label">{k.category}</span><span className={'status '+(k.approved?'Completed':'NeedsAttention')}>{k.approved?'Approved':'Not approved'}</span></div><h2>{k.title}</h2><p>{k.answer}</p>{canEdit&&<button className="button text" onClick={()=>setEdit({...k})}>Edit answer<ArrowUpRight size={15}/></button>}</article>)}</div>{!items.length&&<Empty title="What should guests know?" text="Start with check-in times, parking and breakfast information."/>}{edit&&<div className="modal-backdrop"><section className="modal" role="dialog" aria-modal="true" aria-labelledby="knowledge-title"><div className="modal-heading"><h2 id="knowledge-title">{edit.id?'Edit answer':'Add an answer'}</h2><button className="icon-button" onClick={()=>setEdit(null)} aria-label="Close editor"><X size={20}/></button></div><form onSubmit={save}><label>Title<input autoFocus value={edit.title} maxLength={120} minLength={2} required onChange={e=>setEdit({...edit,title:e.target.value})}/></label><label>Category<input value={edit.category} maxLength={50} required onChange={e=>setEdit({...edit,category:e.target.value})}/></label><label>Answer<textarea value={edit.answer} rows={6} maxLength={5000} minLength={2} required onChange={e=>setEdit({...edit,answer:e.target.value})}/></label><label>Helpful keywords<input value={edit.keywords} maxLength={300} placeholder="parking, car, arrival" onChange={e=>setEdit({...edit,keywords:e.target.value})}/></label><label className="checkbox-label"><input type="checkbox" checked={edit.approved} onChange={e=>setEdit({...edit,approved:e.target.checked})}/>Approved for staff to use</label><div className="form-actions"><button type="button" className="button secondary" onClick={()=>setEdit(null)}>Cancel</button><button className="button primary" disabled={busy}>Save answer</button></div></form></section></div>}</div>;
|
||||
}
|
||||
function SettingsPage({hotel,mailboxes,preview,owner,busy,run,onSave,onMailboxes}:{hotel:Hotel;mailboxes:Mailboxes;preview:boolean;owner:boolean;busy:boolean;run:Run;onSave:(h:Hotel)=>void;onMailboxes:(value:Mailboxes)=>void}){
|
||||
function SettingsPage({hotel,mailboxes,preview,owner,integrations=false,busy,run,onSave,onMailboxes}:{hotel:Hotel;mailboxes:Mailboxes;preview:boolean;owner:boolean;integrations?:boolean;busy:boolean;run:Run;onSave:(h:Hotel)=>void;onMailboxes:(value:Mailboxes)=>void}){
|
||||
const [form,setForm]=useState(hotel);useEffect(()=>setForm(hotel),[hotel]);
|
||||
const result=new URLSearchParams(location.search).get('google');
|
||||
return <div className="page settings-page"><PageHeading eyebrow="Make yourself at home" title="Hotel settings" text="The details that make this workspace yours."/>{result&&<div className={result==='connected'?'success-note':'alert'} role="status">{result==='connected'?'Google mailbox connected. Recent messages will appear after synchronization.':result==='cancelled'?'Google connection was cancelled.':'Google connection could not be completed. Try again or contact your administrator.'}</div>}<section className="settings-card"><div className="section-heading"><Building2 size={20}/><div><h2>Your hotel</h2><p>Shared with everyone in this workspace.</p></div></div><form onSubmit={e=>{e.preventDefault();run(async()=>onSave(await api<Hotel>('/hotel','PUT',form)));}}><fieldset disabled={!owner||busy}><div className="form-grid"><label>Hotel name<input value={form.name} minLength={2} maxLength={120} required onChange={e=>setForm({...form,name:e.target.value})}/></label><label>Timezone<select value={form.timezone} onChange={e=>setForm({...form,timezone:e.target.value})}>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label></div><label>Email signature<textarea rows={4} maxLength={2000} value={form.signature} onChange={e=>setForm({...form,signature:e.target.value})}/></label><div className="form-actions"><button className="button primary" disabled={busy}>Save hotel settings</button></div></fieldset></form></section><MailboxPanel data={mailboxes} owner={owner} preview={preview} busy={busy} run={run} onChange={onMailboxes}/><ReplyControls hotel={hotel} mailboxes={mailboxes} owner={owner} busy={busy} run={run} onSave={onSave}/></div>;
|
||||
return <div className="page settings-page"><PageHeading eyebrow="Make yourself at home" title="Hotel settings" text="The details that make this workspace yours."/>{result&&<div className={result==='connected'?'success-note':'alert'} role="status">{result==='connected'?'Google mailbox connected. Recent messages will appear after synchronization.':result==='cancelled'?'Google connection was cancelled.':'Google connection could not be completed. Try again or contact your administrator.'}</div>}<section className="settings-card"><div className="section-heading"><Building2 size={20}/><div><h2>Your hotel</h2><p>Shared with everyone in this workspace.</p></div></div><form onSubmit={e=>{e.preventDefault();run(async()=>onSave(await api<Hotel>('/hotel','PUT',form)));}}><fieldset disabled={!owner||busy}><div className="form-grid"><label>Hotel name<input value={form.name} minLength={2} maxLength={120} required onChange={e=>setForm({...form,name:e.target.value})}/></label><label>Timezone<select value={form.timezone} onChange={e=>setForm({...form,timezone:e.target.value})}>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label></div><label>Email signature<textarea rows={4} maxLength={2000} value={form.signature} onChange={e=>setForm({...form,signature:e.target.value})}/></label><div className="form-actions"><button className="button primary" disabled={busy}>Save hotel settings</button></div></fieldset></form></section><MailboxPanel data={mailboxes} owner={integrations} preview={preview} busy={busy} run={run} onChange={onMailboxes}/><ReplyControls hotel={hotel} mailboxes={mailboxes} owner={integrations} busy={busy} run={run} onSave={onSave}/></div>;
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<App/>);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user