Add hotel team invitations, assisted account recovery and onboarding
This commit is contained in:
parent
5a93c35b18
commit
7fcf51c900
@ -1,6 +1,6 @@
|
||||
# GuestOps Web
|
||||
|
||||
A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This migration now includes AI draft generation, staff-approved Gmail sending reviewed OHIP reservation updates and NMI hosted invoices. It is not yet a production-complete replacement.**
|
||||
A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This migration now includes AI draft generation, staff-approved Gmail sending, reviewed OHIP reservation updates, NMI hosted invoices, controlled FAQ auto-replies and team onboarding. It is not yet a production-complete replacement.**
|
||||
|
||||
## Implemented so far
|
||||
|
||||
@ -13,11 +13,12 @@ A Linux-hosted hotel email workspace, developed separately from the Windows Gues
|
||||
- Windows-independent booking model, validation, email cleaning, JSON extraction and secret redaction migrated from the hardened desktop code. OHIP exact reservation lookup, internal notes and owner-approved stay-date changes are implemented with durable review and read-only reconciliation; writes are off by default.
|
||||
- Owner-reviewed NMI invoice creation, tenant-specific merchant configuration and read-only status/recovery checks. Creation may email the customer a hosted payment link through NMI; it is off by default.
|
||||
- Controlled FAQ auto-replies: exact plain-text questions, owner-reviewed answers, test mode, daily quotas and thread/knowledge rechecks. Live mode defaults off.
|
||||
- Owner-issued staff invitations, assisted password recovery, session invalidation, disabled-account restoration and a hotel setup checklist. See [team access](docs/accounts.md).
|
||||
- Docker image builds, private MongoDB configuration and an Nginx HTTPS example for the Debian sandbox.
|
||||
|
||||
## Explicit limits
|
||||
|
||||
Real email is sent only after server configuration, Google send consent, hotel-owner opt-in and explicit staff approval of a saved reply. Broad natural-language automatic sending, wider PMS workflows, direct payment URLs in replies, staff invitation/password-reset UI, attachments and complete Gmail-thread aggregation are follow-on work. There is no public registration endpoint. Initial hotel owners are provisioned by the server administrator.
|
||||
Staff replies require server configuration, Google send consent, hotel-owner opt-in and explicit approval of a saved reply. Controlled FAQ auto-replies additionally require reviewed rules and live-mode enablement. Broad natural-language automatic sending, wider PMS workflows, direct payment URLs in replies, self-service recovery emails, granular roles, attachments and complete Gmail-thread aggregation are follow-on work. There is no public registration endpoint. Initial hotel owners are provisioned by the server administrator.
|
||||
|
||||
The Google integration needs OAuth credentials and a sandbox mailbox before its live behaviour can be accepted. Automated tests do not access Gmail or a hotel system. An integration being implemented is not a claim of Google verification or production readiness.
|
||||
|
||||
@ -63,3 +64,4 @@ Set `MONGO_TEST_URI` to an isolated MongoDB server and `TEST_API_URL=http://127.
|
||||
See [controlled FAQ automation](docs/auto-replies.md), [NMI payment setup and recovery](docs/payments.md), [OHIP reservation setup and recovery](docs/pms.md), [AI drafts and reply delivery setup](docs/replies.md), [migration status](docs/migration.md) and [deployment guide](docs/deployment.md).
|
||||
|
||||
|
||||
|
||||
|
||||
44
docs/accounts.md
Normal file
44
docs/accounts.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Team access and hotel setup
|
||||
|
||||
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
|
||||
|
||||
1. Enter the colleague's name and work email in Your team and create an invitation link.
|
||||
2. Verify the intended recipient and share the link privately through your established workplace channel. GuestOps does **not** email the link. The link grants access to set that account's password; treat it as a temporary credential.
|
||||
3. The colleague opens the link, chooses and confirms a unique password of 14–128 characters, then signs in normally. Staff cannot manage hotel controls or team accounts.
|
||||
|
||||
Invitations expire after 48 hours. **Reset password** issues a 30-minute link for an active staff account. The current password and sessions continue to work until the reset is accepted; then previous sessions are invalidated on their next request. **Revoke link** invalidates an outstanding link without changing an active account's password. Issuing another link replaces the previous one.
|
||||
|
||||
**Disable access** invalidates existing sessions and outstanding links. **Restore access** issues a 48-hour link that requires a new password before the disabled account becomes active. It does not restore access using the old password. Owner accounts cannot be disabled or recovered through staff controls.
|
||||
|
||||
An email belongs to one hotel account. Existing active accounts cannot be reassigned through an invitation. The 50-account pilot limit is a best-effort administrative limit, not an atomic quota. Roles in this release are Owner and Staff; granular roles, per-user preferences, MFA, public registration and self-service email recovery are future work.
|
||||
|
||||
## Owner recovery on the Linux server
|
||||
|
||||
The server administrator must verify the owner's identity before issuing a recovery link. On the server, in the directory containing the deployed Compose file and its private environment file:
|
||||
|
||||
```sh
|
||||
read -r -p 'Verified owner email: ' RECOVERY_EMAIL
|
||||
export RECOVERY_EMAIL
|
||||
docker compose run --rm --no-deps -e RECOVERY_EMAIL api --recover-owner
|
||||
unset RECOVERY_EMAIL
|
||||
```
|
||||
|
||||
The command uses the configured database, generates a private link, prints it to the administrator's terminal and exits. Share it only with the verified owner. Do not paste it into tickets, chat logs, build logs or source control; avoid running this command in a recorded terminal. It expires in 30 minutes and can be consumed once. Running the command again invalidates the earlier link. Bootstrap remains a create-only command and cannot reset passwords.
|
||||
|
||||
`PublicUrl` must be the configured HTTPS origin, normally `https://sandbox-guestops.futuresens.co.uk`. Request Host headers never determine recovery-link origins. The Development-only preview uses `http://127.0.0.1:5173`.
|
||||
|
||||
## Storage and session behavior
|
||||
|
||||
MongoDB stores the SHA-256 hash of a random 256-bit token, its purpose and expiry. The raw token is returned only when issuing the link. Expiry is checked during inspection and again during atomic acceptance. Account records are not TTL-deleted when a link expires. Password hashing, version checks and a new security stamp prevent concurrent reuse and invalidate older sessions. Existing pre-migration accounts default to an empty stamp; their cookies remain valid until reset, disablement or normal expiry.
|
||||
|
||||
Tokens travel in URL fragments and are removed from browser history as the account page opens; submission uses POST with CSRF validation. API responses are not cached and referrer policy is `no-referrer`. The frontend holds the token only in component memory. Reloading after the fragment was removed requires reopening the original link. No analytics or third-party scripts are included on this page.
|
||||
|
||||
Account endpoints have rate limits. Because the reverse proxy currently forwards HTTPS status but not client IP addresses, anonymous recovery limits are shared behind that proxy. This can temporarily limit concurrent users; per-client limiting requires a separately reviewed trusted-proxy configuration.
|
||||
|
||||
Account changes appear in workspace activity without links, password hashes or tokens. Automated notification emails are not implemented, including password-change alerts. Recovery is an administrator-assisted process until verified transactional email is added. This is not a claim of production identity-provider completeness.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Automated tests cover invitation/recovery acceptance, concurrent single-use enforcement with real MongoDB, expiry, reissue/revocation, session invalidation, staff restrictions, owner protection, cross-hotel access, configured origins and secret-free API views. Before inviting real staff, verify the deployed HTTPS link, private handoff process, owner recovery command and backup/restore procedure with test accounts.
|
||||
@ -12,7 +12,7 @@ Local JSON stores and process mutexes are not reused in production. MongoDB enfo
|
||||
|
||||
The UI is an operational inbox rather than a port of the desktop booking grid. Real installations start empty. The sample hotel exists only in explicitly enabled Development preview mode. The preview banner remains visible at compact widths.
|
||||
|
||||
Authentication uses ASP.NET cookie protection and its password hasher. Sessions expire after eight hours and validate the user's active status and role on each request. Owner provisioning is an administrator CLI operation; no staff invitation or self-service recovery flow is claimed yet. A staff-facing pilot should not expand beyond administrator-supported accounts until those flows are added.
|
||||
Authentication uses ASP.NET cookie protection and its password hasher. Sessions expire after eight hours and validate the user's active status and role on each request. Owner provisioning remains an administrator CLI operation. Milestone 6 adds owner-issued staff invitations and assisted account recovery; transactional email recovery remains future work.
|
||||
|
||||
Gmail permissions are read-only. The worker fetches plain-text bodies and skips automated/list/bounce messages. It does not fetch attachments, mark messages read, delete them, send mail, or call a PMS. Initial import is seven days, 25 messages per worker cycle. Unfinished pages retain their checkpoint, and overlap between synchronization windows is deduplicated. Invalid provider pagination tokens currently require operator reconnection/reset of the mailbox checkpoint; there is no full history-repair UI yet.
|
||||
|
||||
@ -32,10 +32,14 @@ Implemented owner-reviewed invoice creation, unique payment references, customer
|
||||
|
||||
Implemented seven exact FAQ question rules, approved-answer version binding, test/live modes, durable daily quotas, Gmail thread rechecks and staff handover for rejected automatic replies. Plain-text messages only; broad natural-language matching is not claimed. Live Gmail and rule acceptance remains pending. See [automation setup and limits](auto-replies.md).
|
||||
|
||||
## Milestone 6: team accounts and hotel onboarding
|
||||
|
||||
Implemented owner-issued single-use invitation and recovery links, staff disable/restore controls, session invalidation after password changes, server-admin owner recovery and a setup checklist derived from saved hotel state. Links are copied and shared privately; GuestOps does not send recovery emails. See [account setup and limits](accounts.md).
|
||||
|
||||
## Remaining milestones
|
||||
|
||||
1. Test Google connection with a dedicated test mailbox; add provider fixture tests, disconnect/revocation, refresh failure recovery and full thread aggregation.
|
||||
2. Add staff invitations, account recovery, granular roles, user preferences and an onboarding wizard.
|
||||
2. Add verified transactional email for invitations and recovery notifications, MFA, granular roles and user preferences.
|
||||
3. Run a representative live AI draft evaluation, improve retrieval and evidence presentation, and approve the selected provider data-processing arrangements.
|
||||
4. Extend the implemented durable reply queue with operator recovery tooling and broaden the controlled FAQ rules only after live acceptance. Preserve the rule that uncertain sends are never blindly replayed.
|
||||
5. Validate the OHIP adapter against the property sandbox, extend supported PMS operations and validate NMI hosted invoices with the merchant sandbox. Add direct payment URLs only when a supported provider contract is available. Do not enable these by merely copying desktop settings or toggling a feature flag.
|
||||
@ -47,3 +51,4 @@ Windows DPAPI settings must not be copied to Linux as usable credentials. Re-ent
|
||||
Target supplied by the owner: Debian 12, 4 CPU cores, 7.6 GiB RAM, 18 GiB free disk; MongoDB on the same machine. Compose includes conservative starting memory limits and capped logs, not a capacity guarantee. Keep database/key backups off-server and do not import entire mailboxes by default. Establish retention and restore testing before real guest data is used.
|
||||
|
||||
|
||||
|
||||
|
||||
@ -24,6 +24,11 @@ public class Hotel : TenantDocument
|
||||
}
|
||||
public class StaffUser : TenantDocument
|
||||
{
|
||||
public long Version {get;set;}
|
||||
public string SecurityStamp {get;set;}="";
|
||||
public string AccountLinkHash {get;set;}="";
|
||||
public string AccountLinkPurpose {get;set;}="";
|
||||
public DateTime? AccountLinkExpiresAt {get;set;}
|
||||
public string Email { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string PasswordHash { get; set; } = "";
|
||||
|
||||
@ -12,7 +12,8 @@ using Microsoft.AspNetCore.HttpOverrides;
|
||||
using System.Net;
|
||||
|
||||
var bootstrap = args.Contains("--bootstrap");
|
||||
var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap").ToArray());
|
||||
var recoverOwner = args.Contains("--recover-owner");
|
||||
var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap" && arg != "--recover-owner").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();
|
||||
@ -35,6 +36,7 @@ builder.Services.AddTransient<PmsWork>();
|
||||
builder.Services.AddHttpClient<NmiInvoices>(c=>c.Timeout=TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(()=>new HttpClientHandler{AllowAutoRedirect=false});
|
||||
builder.Services.AddTransient<PaymentWork>();
|
||||
builder.Services.AddTransient<AutoReplyWork>();
|
||||
builder.Services.AddTransient<TeamAccounts>();
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o =>
|
||||
{
|
||||
o.Cookie.Name = "guestops.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Lax;
|
||||
@ -46,7 +48,7 @@ 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 || !user.Active || user.Role != c.Principal!.FindFirstValue(ClaimTypes.Role)) c.RejectPrincipal();
|
||||
if (user == null || !TeamAccounts.SessionValid(user,c.Principal?.FindFirstValue("security_stamp")) || user.Role != c.Principal!.FindFirstValue(ClaimTypes.Role)) c.RejectPrincipal();
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner")));
|
||||
@ -65,18 +67,26 @@ builder.Services.AddRateLimiter(o =>
|
||||
o.RejectionStatusCode = 429;
|
||||
o.AddPolicy("pms", context => RateLimitPartition.GetFixedWindowLimiter(context.User.FindFirstValue("hotel") ?? "anonymous", _ => new() { PermitLimit = 30, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
o.AddPolicy("ai", context => RateLimitPartition.GetFixedWindowLimiter(context.User.FindFirstValue("hotel") ?? "anonymous", _ => new() { PermitLimit = 6, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
o.AddPolicy("accounts", context => RateLimitPartition.GetFixedWindowLimiter(context.User.FindFirstValue("hotel") ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new() { PermitLimit = 30, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
o.AddPolicy("login", context => RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new() { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
});
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
var store = app.Services.GetRequiredService<IStore>();
|
||||
await store.Initialize();
|
||||
if(recoverOwner)
|
||||
{
|
||||
var email=Environment.GetEnvironmentVariable("RECOVERY_EMAIL")?.Trim().ToLowerInvariant()??"";
|
||||
var user=await store.FindLogin(email);if(user==null)throw new InvalidOperationException("An active owner account is required.");
|
||||
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 (bootstrap)
|
||||
{
|
||||
var email = Environment.GetEnvironmentVariable("BOOTSTRAP_EMAIL")?.Trim().ToLowerInvariant() ?? "";
|
||||
var password = Environment.GetEnvironmentVariable("BOOTSTRAP_PASSWORD") ?? "";
|
||||
var hotelName = Environment.GetEnvironmentVariable("BOOTSTRAP_HOTEL") ?? "";
|
||||
if (!Input.Email(email) || password.Length < 14 || hotelName.Length < 2) throw new InvalidOperationException("Set BOOTSTRAP_EMAIL, BOOTSTRAP_PASSWORD (14+ characters), and BOOTSTRAP_HOTEL.");
|
||||
if (!Input.Email(email) || !TeamAccounts.PasswordValid(password) || hotelName.Length < 2) throw new InvalidOperationException("Set BOOTSTRAP_EMAIL, BOOTSTRAP_PASSWORD (14 to 128 characters), and BOOTSTRAP_HOTEL.");
|
||||
if (await store.FindLogin(email) != null) throw new InvalidOperationException("Account already exists; bootstrap does not reset credentials.");
|
||||
var hotel = new Hotel { Name = hotelName }; hotel.HotelId = hotel.Id;
|
||||
var user = new StaffUser { HotelId = hotel.Id, Email = email, Name = "Hotel owner" };
|
||||
@ -87,10 +97,12 @@ if (bootstrap)
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
ctx.Response.Headers["Referrer-Policy"] = "same-origin";
|
||||
ctx.Response.Headers["Referrer-Policy"] = "no-referrer";
|
||||
ctx.Response.Headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
||||
if (ctx.Request.Path.StartsWithSegments("/api")) ctx.Response.Headers.CacheControl = "no-store";
|
||||
if (ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/account")) ctx.Response.Headers.CacheControl = "no-store";
|
||||
try { await next(); }
|
||||
catch (AccountInvalid ex) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); }
|
||||
catch (AccountConflict ex) { ctx.Response.StatusCode = 409; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); }
|
||||
catch (PaymentInvalid ex) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); }
|
||||
catch (PaymentConflict ex) { ctx.Response.StatusCode = 409; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); }
|
||||
catch (PmsInvalid ex) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); }
|
||||
@ -117,11 +129,11 @@ app.MapGet("/api/session", (HttpContext c, IAntiforgery csrf) => Results.Ok(new
|
||||
}));
|
||||
app.MapPost("/api/auth/login", async (LoginInput input, HttpContext c, IPasswordHasher<StaffUser> hasher) =>
|
||||
{
|
||||
if (input.Email.Length > 254 || input.Password.Length > 256) return Results.BadRequest(new { error = "Invalid credentials." });
|
||||
if (input.Email == null || input.Password == null || input.Email.Length > 254 || input.Password.Length > 256) return Results.BadRequest(new { error = "Invalid credentials." });
|
||||
var user = await store.FindLogin(input.Email.Trim().ToLowerInvariant());
|
||||
// Verify a dummy hash too so unknown accounts do not have a fast password path.
|
||||
var checkUser = user ?? new StaffUser();
|
||||
var hash = user?.PasswordHash ?? Input.DummyHash;
|
||||
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();
|
||||
@ -132,6 +144,7 @@ var api = app.MapGroup("/api").RequireAuthorization();
|
||||
PmsEndpoints.Map(api,preview);
|
||||
PaymentEndpoints.Map(api,preview);
|
||||
AutoReplyEndpoints.Map(api,preview);
|
||||
TeamEndpoints.Map(app,api,preview);
|
||||
api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get<Hotel>(Session.Hotel(c), Session.Hotel(c))));
|
||||
api.MapPut("/hotel", async (SettingsInput input, HttpContext c) =>
|
||||
{
|
||||
@ -271,7 +284,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) }, CookieAuthenticationDefaults.AuthenticationScheme)));
|
||||
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 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
|
||||
@ -283,3 +296,5 @@ namespace GuestOps.Web
|
||||
public static IResult Conflict() => Results.Conflict(new { error = "Someone changed this record. Reload it before saving again." });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -13,6 +13,9 @@ public interface IStore
|
||||
Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument;
|
||||
Task Delete<T>(string hotel, string id) where T : TenantDocument;
|
||||
Task<StaffUser?> FindLogin(string email);
|
||||
Task<StaffUser?> FindAccountLink(string hash);
|
||||
Task<bool> TryInsertStaff(StaffUser user);
|
||||
Task<bool> ConsumeAccountLink(StaffUser user,long version,string hash);
|
||||
Task<OAuthRequest?> ConsumeOAuth(string id, string hotel, string user);
|
||||
Task<List<Mailbox>> Mailboxes();
|
||||
Task SaveMailbox(Mailbox mailbox);
|
||||
@ -45,6 +48,7 @@ public sealed class MongoStore : IStore
|
||||
}
|
||||
public async Task Initialize()
|
||||
{
|
||||
await Collection<StaffUser>().Indexes.CreateOneAsync(new CreateIndexModel<StaffUser>(Builders<StaffUser>.IndexKeys.Ascending(x=>x.AccountLinkHash)));
|
||||
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}));
|
||||
@ -76,10 +80,19 @@ public sealed class MongoStore : IStore
|
||||
public async Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument
|
||||
{
|
||||
if (document.HotelId != hotel || document.Id != id) throw new InvalidOperationException("Invalid document scope.");
|
||||
var result = await Collection<T>().ReplaceOneAsync(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id) & Builders<T>.Filter.Eq("Version", version), document);
|
||||
var versionFilter=Builders<T>.Filter.Eq("Version",version);
|
||||
if(typeof(T)==typeof(StaffUser)&&version==0)versionFilter|=Builders<T>.Filter.Exists("Version",false);
|
||||
var result = await Collection<T>().ReplaceOneAsync(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id) & versionFilter, document);
|
||||
return result.ModifiedCount == 1;
|
||||
}
|
||||
public async Task Delete<T>(string hotel, string id) where T : TenantDocument => await Collection<T>().DeleteOneAsync(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id));
|
||||
public async Task<StaffUser?> FindAccountLink(string hash)=>await Collection<StaffUser>().Find(x=>x.AccountLinkHash==hash&&x.AccountLinkExpiresAt>DateTime.UtcNow).FirstOrDefaultAsync();
|
||||
public async Task<bool> TryInsertStaff(StaffUser user){try{await Insert(user);return true;}catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return false;}}
|
||||
public async Task<bool> ConsumeAccountLink(StaffUser user,long version,string hash)
|
||||
{
|
||||
var filter=Scope<StaffUser>(user.HotelId)&Builders<StaffUser>.Filter.Eq(x=>x.Id,user.Id)&Builders<StaffUser>.Filter.Eq(x=>x.Version,version)&Builders<StaffUser>.Filter.Eq(x=>x.AccountLinkHash,hash)&Builders<StaffUser>.Filter.Gt(x=>x.AccountLinkExpiresAt,DateTime.UtcNow);
|
||||
return (await Collection<StaffUser>().ReplaceOneAsync(filter,user)).ModifiedCount==1;
|
||||
}
|
||||
public async Task<StaffUser?> FindLogin(string email) => await Collection<StaffUser>().Find(x => x.Email == email).FirstOrDefaultAsync();
|
||||
public async Task<OAuthRequest?> ConsumeOAuth(string id, string hotel, string user) => await Collection<OAuthRequest>().FindOneAndDeleteAsync(x => x.Id == id && x.HotelId == hotel && x.UserId == user && x.ExpiresAt > DateTime.UtcNow);
|
||||
public Task<List<Mailbox>> Mailboxes() => Collection<Mailbox>().Find(x => x.Status == "Connected").ToListAsync();
|
||||
@ -167,6 +180,12 @@ public sealed class PreviewStore : IStore
|
||||
}
|
||||
}
|
||||
public async Task Delete<T>(string hotel, string id) where T : TenantDocument { if (await Get<T>(hotel, id) != null) rows.TryRemove(Key<T>(id), out _); }
|
||||
public Task<StaffUser?> FindAccountLink(string hash)=>Task.FromResult(rows.Where(x=>x.Key.StartsWith("StaffUser:")).Select(x=>Clone<StaffUser>(x.Value)).SingleOrDefault(x=>x.AccountLinkHash==hash&&x.AccountLinkExpiresAt>DateTime.UtcNow));
|
||||
public Task<bool> TryInsertStaff(StaffUser user){lock(gate){if(rows.Where(x=>x.Key.StartsWith("StaffUser:")).Select(x=>Clone<StaffUser>(x.Value)).Any(x=>x.Email==user.Email))return Task.FromResult(false);return Task.FromResult(rows.TryAdd(Key<StaffUser>(user.Id),Json(user)));}}
|
||||
public Task<bool> ConsumeAccountLink(StaffUser user,long version,string hash)
|
||||
{
|
||||
lock(gate){if(!rows.TryGetValue(Key<StaffUser>(user.Id),out var raw))return Task.FromResult(false);var old=Clone<StaffUser>(raw);if(old.HotelId!=user.HotelId||old.Version!=version||old.AccountLinkHash!=hash||old.AccountLinkExpiresAt<=DateTime.UtcNow||old.AccountLinkExpiresAt==null)return Task.FromResult(false);rows[Key<StaffUser>(user.Id)]=Json(user);return Task.FromResult(true);}
|
||||
}
|
||||
public Task<StaffUser?> FindLogin(string email) => Task.FromResult(rows.Where(x => x.Key.StartsWith("StaffUser:")).Select(x => Clone<StaffUser>(x.Value)).SingleOrDefault(x => x.Email == email));
|
||||
public Task<OAuthRequest?> ConsumeOAuth(string id, string hotel, string user) { lock(gate) { var x = rows.TryGetValue(Key<OAuthRequest>(id), out var raw) ? Clone<OAuthRequest>(raw) : null; if(x?.HotelId != hotel || x.UserId != user || x.ExpiresAt <= DateTime.UtcNow) return Task.FromResult<OAuthRequest?>(null); rows.TryRemove(Key<OAuthRequest>(id),out _); return Task.FromResult<OAuthRequest?>(x); } }
|
||||
public Task<List<Mailbox>> Mailboxes() => Task.FromResult(new List<Mailbox>());
|
||||
|
||||
91
src/GuestOps.Api/TeamAccounts.cs
Normal file
91
src/GuestOps.Api/TeamAccounts.cs
Normal file
@ -0,0 +1,91 @@
|
||||
using System.Security.Cryptography;
|
||||
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 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 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 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 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)));
|
||||
string BaseUrl()
|
||||
{
|
||||
if(config.GetValue<bool>("Preview"))return "http://127.0.0.1:5173";
|
||||
var value=config["PublicUrl"];
|
||||
if(!Uri.TryCreate(value,UriKind.Absolute,out var uri)||uri.Scheme!="https"||uri.Port!=443||uri.IsLoopback||uri.UserInfo.Length>0||uri.AbsolutePath!="/"||uri.Query.Length>0||uri.Fragment.Length>0)throw new AccountInvalid("The administrator must configure the public HTTPS address before issuing account links.");
|
||||
return uri.GetLeftPart(UriPartial.Authority);
|
||||
}
|
||||
public async Task<AccountLinkResult> Invite(string hotel,InviteInput input)
|
||||
{
|
||||
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 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)
|
||||
{
|
||||
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};
|
||||
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));
|
||||
}
|
||||
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.");
|
||||
return Issue(user,"Reset",TimeSpan.FromMinutes(30));
|
||||
}
|
||||
public Task<AccountLinkResult> RecoverOwner(StaffUser user)
|
||||
{
|
||||
if(user.Role!="Owner"||!user.Active)throw new AccountInvalid("An active owner account is required.");return Issue(user,"Reset",TimeSpan.FromMinutes(30));
|
||||
}
|
||||
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.");
|
||||
return Issue(user,"Restore",TimeSpan.FromHours(48));
|
||||
}
|
||||
async Task<AccountLinkResult> Issue(StaffUser user,string purpose,TimeSpan lifetime)
|
||||
{
|
||||
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);
|
||||
}
|
||||
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;
|
||||
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;
|
||||
user.PasswordHash=hasher.HashPassword(user,input.Password);user.Active=true;user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++;
|
||||
user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;
|
||||
return await store.ConsumeAccountLink(user,version,hash);
|
||||
}
|
||||
public async Task<bool> Disable(StaffUser user,long version)
|
||||
{
|
||||
if(user.Role!="Staff"||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);
|
||||
}
|
||||
public async Task<bool> Revoke(StaffUser user,long version)
|
||||
{
|
||||
if(user.Role!="Staff"||user.Version!=version)return false;
|
||||
user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;user.Version++;
|
||||
return await store.Replace(user.HotelId,user.Id,version,user);
|
||||
}
|
||||
}
|
||||
53
src/GuestOps.Api/TeamEndpoints.cs
Normal file
53
src/GuestOps.Api/TeamEndpoints.cs
Normal file
@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
namespace GuestOps.Web;
|
||||
public static class TeamEndpoints
|
||||
{
|
||||
public static void Map(WebApplication app,RouteGroupBuilder api,bool preview)
|
||||
{
|
||||
app.MapPost("/api/account-links/inspect",async(AccountTokenInput input,TeamAccounts accounts,IStore store)=>
|
||||
{
|
||||
var user=await accounts.Inspect(input.Token);if(user==null)return Results.BadRequest(new {error="This link is invalid or has expired. Ask for a new link."});
|
||||
var hotel=await store.Get<Hotel>(user.HotelId,user.HotelId);
|
||||
return Results.Ok(new {user.Name,user.Email,purpose=user.AccountLinkPurpose,expiresAt=user.AccountLinkExpiresAt,hotelName=hotel?.Name});
|
||||
}).RequireRateLimiting("accounts");
|
||||
app.MapPost("/api/account-links/accept",async(AccountAcceptInput input,TeamAccounts accounts,IStore store,HttpContext c)=>
|
||||
{
|
||||
var user=await accounts.Inspect(input.Token);
|
||||
if(user==null||!await accounts.Accept(input))return Results.BadRequest(new {error="This link is invalid or has expired. Ask for a new link."});
|
||||
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");
|
||||
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);
|
||||
}).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."});
|
||||
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);
|
||||
}
|
||||
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();
|
||||
}).RequireRateLimiting("accounts");
|
||||
api.MapGet("/onboarding",async(HttpContext c,IStore store)=>
|
||||
{
|
||||
var id=Session.Hotel(c);var hotel=await store.Get<Hotel>(id,id);
|
||||
var knowledge=await store.List<KnowledgeEntry>(id);var mailboxes=await store.List<Mailbox>(id);var users=await store.List<StaffUser>(id);
|
||||
return Results.Ok(new {preview,steps=new[]{
|
||||
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="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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ IStore store = uri == null ? new PreviewStore() : new MongoStore(new Configurati
|
||||
await store.Initialize();
|
||||
try
|
||||
{
|
||||
await TeamTests.Run(Check, store);
|
||||
await ReplyTests.Run(Check, store);
|
||||
await PmsTests.Run(Check, store);
|
||||
await PaymentTests.Run(Check, store);
|
||||
@ -106,6 +107,7 @@ try
|
||||
Check("FAQ content tester returns a match without a delivery",autoTest.IsSuccessStatusCode&&JsonDocument.Parse(await autoTest.Content.ReadAsStringAsync()).RootElement.GetProperty("matches").GetBoolean());
|
||||
Check("FAQ rule updates reject stale versions",(await one.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=false,version=0})).StatusCode==HttpStatusCode.Conflict);
|
||||
Check("Uncertain or foreign replies cannot be released",(await two.PostAsJsonAsync($"/api/conversations/{id}/delivery/release",new{version=0})).StatusCode==HttpStatusCode.NotFound);
|
||||
await TeamTests.Http(Check,one,two,baseUrl);
|
||||
var cookie=(await one.GetAsync("/api/session")).Headers;
|
||||
Check("Session response is not cacheable", cookie.CacheControl?.NoStore==true);
|
||||
await one.PostAsJsonAsync("/api/auth/logout",new {});
|
||||
@ -121,3 +123,4 @@ finally
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
75
tests/GuestOps.Tests/TeamTests.cs
Normal file
75
tests/GuestOps.Tests/TeamTests.cs
Normal file
@ -0,0 +1,75 @@
|
||||
using GuestOps.Web;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
public static class TeamTests
|
||||
{
|
||||
static string Token(AccountLinkResult link)=>link.Link.Split("#token=")[1];
|
||||
public static async Task Run(Action<string,bool> check,IStore store)
|
||||
{
|
||||
var hotel=Guid.NewGuid().ToString("N");var email=hotel+"@example.invalid";
|
||||
var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{{"PublicUrl","https://hotel.example.invalid"}}).Build();
|
||||
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("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);
|
||||
var replacement=await service.Invite(hotel,new("Test Colleague",email));
|
||||
check("Reissued invitation invalidates earlier token",await service.Inspect(token)==null);
|
||||
var password="Test-only-passphrase-2026!";
|
||||
denied=false;try{await service.Accept(new(Token(replacement),password,"different"));}catch(AccountInvalid){denied=true;}check("Password confirmation mismatch preserves invitation",denied&&await service.Inspect(Token(replacement))!=null);
|
||||
var attempts=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>service.Accept(new(Token(replacement),password,password))));
|
||||
check("Concurrent invitation acceptance succeeds exactly once",attempts.Count(x=>x)==1);
|
||||
user=(await store.Get<StaffUser>(hotel,link.UserId))!;
|
||||
check("Accepted invitation activates hashed password and clears link",user.Active&&user.AccountLinkHash==""&&hasher.VerifyHashedPassword(user,user.PasswordHash,password)!=PasswordVerificationResult.Failed);
|
||||
check("Consumed invitation cannot be reused",!await service.Accept(new(Token(replacement),password,password)));
|
||||
var stamp=user.SecurityStamp;var reset=await service.ResetStaff(user,user.Version);
|
||||
user=(await store.Get<StaffUser>(hotel,user.Id))!;check("Issuing reset preserves current session",TeamAccounts.SessionValid(user,stamp));
|
||||
await service.Accept(new(Token(reset),password+"new",password+"new"));user=(await store.Get<StaffUser>(hotel,user.Id))!;
|
||||
check("Accepted reset invalidates previous sessions",!TeamAccounts.SessionValid(user,stamp)&&TeamAccounts.SessionValid(user,user.SecurityStamp));
|
||||
var stale=user.Version;reset=await service.ResetStaff(user,user.Version);user=(await store.Get<StaffUser>(hotel,user.Id))!;
|
||||
check("Stale staff disable is rejected",!await service.Disable(user,stale));
|
||||
check("Owner can revoke an unused recovery link",await service.Revoke(user,user.Version)&&await service.Inspect(Token(reset))==null);
|
||||
user=(await store.Get<StaffUser>(hotel,user.Id))!;reset=await service.ResetStaff(user,user.Version);user=(await store.Get<StaffUser>(hotel,user.Id))!;
|
||||
user.AccountLinkExpiresAt=DateTime.UtcNow.AddMinutes(-1);var v=user.Version;user.Version++;await store.Replace(hotel,user.Id,v,user);
|
||||
check("Expired recovery link is rejected",await service.Inspect(Token(reset))==null);
|
||||
user=(await store.Get<StaffUser>(hotel,user.Id))!;await service.Disable(user,user.Version);user=(await store.Get<StaffUser>(hotel,user.Id))!;
|
||||
check("Disabled staff loses sessions",!TeamAccounts.SessionValid(user,user.SecurityStamp));
|
||||
var restore=await service.Restore(user,user.Version);check("Restoration does not reactivate old password",!(await store.Get<StaffUser>(hotel,user.Id))!.Active);
|
||||
check("Restoration requires a new password through single-use link",await service.Accept(new(Token(restore),password,password)));
|
||||
var owner=new StaffUser{HotelId=hotel,Email="owner-"+email,Name="Owner",PasswordHash=hasher.HashPassword(new(),password)};await store.Insert(owner);
|
||||
check("Team controls cannot disable owner",!await service.Disable(owner,owner.Version));
|
||||
denied=false;try{await service.ResetStaff(owner,owner.Version);}catch(AccountConflict){denied=true;}check("Team controls cannot reset owner",denied);
|
||||
var ownerReset=await service.RecoverOwner(owner);check("Server admin can issue owner recovery",await service.Inspect(Token(ownerReset))!=null);
|
||||
var badConfig=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{{"PublicUrl","http://hotel.example.invalid"}}).Build();
|
||||
denied=false;try{await new TeamAccounts(store,hasher,badConfig).Invite(hotel,new("No Account","bad-"+email));}catch(AccountInvalid){denied=true;}check("Untrusted public URL rejects link before inserting account",denied&&await store.FindLogin("bad-"+email)==null);
|
||||
var safe=JsonSerializer.Serialize(TeamAccounts.View(user));check("Team views omit password, token hash and security stamp",!safe.Contains("Hash")&&!safe.Contains("Stamp"));
|
||||
}
|
||||
public static async Task Http(Action<string,bool> check,HttpClient owner,HttpClient other,string baseUrl)
|
||||
{
|
||||
async Task<JsonElement> Read(HttpResponseMessage response){response.EnsureSuccessStatusCode();return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone();}
|
||||
async Task Csrf(HttpClient c){var s=await Read(await c.GetAsync("/api/session"));c.DefaultRequestHeaders.Remove("X-CSRF-TOKEN");c.DefaultRequestHeaders.Add("X-CSRF-TOKEN",s.GetProperty("csrfToken").GetString());}
|
||||
var email="staff-"+Guid.NewGuid().ToString("N")+"@example.invalid";
|
||||
var invite=await Read(await owner.PostAsJsonAsync("/api/team/invite",new{name="HTTP Colleague",email}));var id=invite.GetProperty("userId").GetString();var token=invite.GetProperty("link").GetString()!.Split("#token=")[1];
|
||||
using var staff=new HttpClient(new HttpClientHandler{CookieContainer=new CookieContainer(),AllowAutoRedirect=false}){BaseAddress=new Uri(baseUrl)};
|
||||
check("Invitation consumption requires CSRF",(await staff.PostAsJsonAsync("/api/account-links/inspect",new{token})).StatusCode==HttpStatusCode.BadRequest);await Csrf(staff);
|
||||
check("Invitation inspection works without signing in",(await staff.PostAsJsonAsync("/api/account-links/inspect",new{token})).IsSuccessStatusCode);
|
||||
var password="HTTP-test-passphrase-2026!";check("Invitation acceptance works",(await staff.PostAsJsonAsync("/api/account-links/accept",new{token,password,confirmPassword=password})).IsSuccessStatusCode);
|
||||
check("Invitation acceptance does not automatically sign in",(await staff.GetAsync("/api/hotel")).StatusCode==HttpStatusCode.Unauthorized);
|
||||
await Csrf(staff);check("Invited colleague can sign in",(await staff.PostAsJsonAsync("/api/auth/login",new{email,password})).IsSuccessStatusCode);await Csrf(staff);
|
||||
check("Staff cannot list or invite team members",(await staff.GetAsync("/api/team")).StatusCode==HttpStatusCode.Forbidden&&(await staff.PostAsJsonAsync("/api/team/invite",new{name="No",email="no@example.invalid"})).StatusCode==HttpStatusCode.Forbidden);
|
||||
check("Staff cannot change hotel settings",(await staff.PutAsJsonAsync("/api/hotel",new{name="No",signature="",timezone="UTC",version=0})).StatusCode==HttpStatusCode.Forbidden);
|
||||
check("Foreign hotel cannot reset staff",(await other.PostAsJsonAsync($"/api/team/{id}/reset",new{version=2})).StatusCode==HttpStatusCode.NotFound);
|
||||
var list=await Read(await owner.GetAsync("/api/team"));var member=list.EnumerateArray().Single(x=>x.GetProperty("id").GetString()==id);var version=member.GetProperty("version").GetInt64();
|
||||
check("HTTP team listing excludes secrets",!list.GetRawText().Contains("passwordHash")&&!list.GetRawText().Contains("securityStamp")&&!list.GetRawText().Contains(token));
|
||||
var reset=await Read(await owner.PostAsJsonAsync($"/api/team/{id}/reset",new{version}));token=reset.GetProperty("link").GetString()!.Split("#token=")[1];
|
||||
using var recovery=new HttpClient(new HttpClientHandler{CookieContainer=new CookieContainer()}){BaseAddress=new Uri(baseUrl)};await Csrf(recovery);
|
||||
check("Staff reset succeeds from separate browser",(await recovery.PostAsJsonAsync("/api/account-links/accept",new{token,password=password+"new",confirmPassword=password+"new"})).IsSuccessStatusCode);
|
||||
check("Password reset invalidates existing HTTP session",(await staff.GetAsync("/api/hotel")).StatusCode==HttpStatusCode.Unauthorized);
|
||||
check("Owner onboarding lists saved setup state",(await Read(await owner.GetAsync("/api/onboarding"))).GetProperty("steps").GetArrayLength()==5);
|
||||
}
|
||||
}
|
||||
@ -52,11 +52,19 @@ assert "securityKey" not in payment_status
|
||||
request("/api/payments/controls", "PUT", {"version": 0, "enabled": True}, expected=400)
|
||||
assert request("/api/payments/requests") == []
|
||||
hotel = request("/api/hotel")
|
||||
assert "root" in request("/account"), "Container must serve account link page"
|
||||
assert len(request("/api/onboarding")["steps"]) == 5
|
||||
team = request("/api/team")
|
||||
assert all("passwordHash" not in member and "securityStamp" not in member and "accountLinkHash" not in member for member in team)
|
||||
if "--read" in sys.argv:
|
||||
assert hotel["signature"] == "Persisted across container restart"
|
||||
invited = next(member for member in team if member["email"] == "ci-staff@example.invalid")
|
||||
assert invited["pending"] and not invited["active"] and invited["linkPurpose"] == "Invite"
|
||||
else:
|
||||
hotel["signature"] = "Persisted across container restart"
|
||||
request("/api/hotel", "PUT", hotel)
|
||||
invite = request("/api/team/invite", "POST", {"name": "CI Staff", "email": "ci-staff@example.invalid"})
|
||||
assert invite["link"].startswith("https://sandbox-guestops.futuresens.co.uk/account#token=")
|
||||
request("/api/auth/logout", "POST")
|
||||
request("/api/hotel", expected=401)
|
||||
print("Production smoke checks passed: built UI, secure cookies, owner login, MongoDB settings and logout.")
|
||||
print("Production smoke checks passed: built UI, secure cookies, owner login, persisted settings and staff invitation, onboarding and logout.")
|
||||
|
||||
30
web/src/TeamPage.tsx
Normal file
30
web/src/TeamPage.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
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}:{owner:boolean}) {
|
||||
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);
|
||||
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);}}
|
||||
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 {new Date(link.expiresAt).toLocaleString()}. 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 {new Date(m.linkExpiresAt!).toLocaleString()}</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>
|
||||
</div>;
|
||||
}
|
||||
type Setup={preview:boolean;steps:{title:string;detail:string;path:string;complete:boolean;optional:boolean}[]};
|
||||
export function OnboardingPage({owner,go}:{owner:boolean;go:(path:string)=>void}){
|
||||
const [data,setData]=useState<Setup|null>(null),[error,setError]=useState('');
|
||||
useEffect(()=>{if(owner)api<Setup>('/onboarding').then(setData).catch(e=>setError(e.message));},[owner]);
|
||||
if(!owner)return <div className="page"><h1>Hotel setup</h1><p>Your hotel owner manages setup.</p></div>;
|
||||
return <div className="page settings-page"><div className="page-heading"><span className="eyebrow">Start with the essentials</span><h1>Welcome to GuestOps</h1><p>Set up a workspace your team can rely on, one step at a time.</p></div>{error&&<div className="alert" role="alert">{error}</div>}{!data&&!error&&<p>Checking hotel setup…</p>}{data&&<><div className="knowledge-summary"><div><strong>{data.steps.filter(s=>!s.optional&&s.complete).length} of {data.steps.filter(s=>!s.optional).length} essentials ready</strong><p>{data.preview?'Sample workspace: real mailbox connections are unavailable.':'Progress reflects saved hotel settings and connections. Review each item before your team starts work.'}</p></div></div>{data.steps.map((s,i)=><section className="settings-card setup-step" key={s.title}><span className={'setup-number '+(s.complete?'complete':'')}>{s.complete?'✓':i+1}</span><div><h2>{s.title}</h2><p>{s.detail}</p><span className="small muted">{s.complete?'Ready to review':s.optional?'Optional next step':'Needs setup'}</span></div><button className="button secondary" onClick={()=>go(s.path)}>{s.complete?'Review':'Open'}</button></section>)}<p className="small muted">This checklist does not activate email sending, PMS changes or payments. Each feature keeps its own approval controls.</p></>}</div>;
|
||||
}
|
||||
export function AccountPage(){
|
||||
const [token]=useState(()=>{const value=new URLSearchParams(location.hash.slice(1)).get('token')||'';history.replaceState({},'',location.pathname);return value;}),[info,setInfo]=useState<{name:string;email:string;hotelName:string;purpose:string}|null>(null),[error,setError]=useState(''),[password,setPassword]=useState(''),[confirmPassword,setConfirm]=useState(''),[busy,setBusy]=useState(false),[done,setDone]=useState(false);
|
||||
useEffect(()=>{if(!token){setError('Open the original invitation or recovery link. If it has expired, ask for a new one.');return;}api<typeof info>('/account-links/inspect','POST',{token}).then(setInfo).catch(e=>setError(e.message));},[token]);
|
||||
return <div className="account-layout"><section className="settings-card"><a className="brand" href="/">guestops.</a><h1>{done?'Your account is ready':info?.purpose==='Invite'?'Join your hotel team':'Set your password'}</h1>{done?<><p>Your password has been saved and previous sessions have ended.</p><a className="button primary" href="/">Continue to sign in</a></>:<>{error&&<div className="alert" role="alert">{error}</div>}{info&&<><p>{info.name} · {info.hotelName}</p><p className="muted">{info.email}</p><form onSubmit={async e=>{e.preventDefault();if(busy)return;setBusy(true);setError('');try{await api('/account-links/accept','POST',{token,password,confirmPassword});setPassword('');setConfirm('');setDone(true);}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}}><label>New password<input type="password" autoComplete="new-password" required minLength={14} maxLength={128} value={password} onChange={e=>setPassword(e.target.value)}/></label><label>Confirm password<input type="password" autoComplete="new-password" required minLength={14} maxLength={128} value={confirmPassword} onChange={e=>setConfirm(e.target.value)}/></label><p className="small muted">Use a unique password of 14 to 128 characters.</p><button className="button primary wide" disabled={busy}>{busy?'Saving…':'Save password'}</button></form></>}<p><a href="/">Back to sign in</a></p></>}</section></div>;
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,8 @@ import React, { useEffect, 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 Knowledge, type Activity, type Mailboxes } from './api';
|
||||
import './style.css';
|
||||
import './style.css';
|
||||
import { TeamPage, OnboardingPage, AccountPage } from './TeamPage';
|
||||
import { AutomationPage } from './AutomationPage';
|
||||
import { PaymentsPage } from './PaymentsPage';
|
||||
import { PmsPage } from './PmsPage';
|
||||
@ -32,6 +33,7 @@ function App() {
|
||||
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;
|
||||
return <div className="app-shell">
|
||||
@ -39,13 +41,13 @@ function App() {
|
||||
<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}].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},{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}].filter(n=>auth.user?.role==='Owner'||!['/team','/setup'].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>
|
||||
<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':'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>
|
||||
<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':'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==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} 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)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage 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==='/team'?<TeamPage owner={auth.user.role==='Owner'}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} 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)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@ -83,3 +85,4 @@ function SettingsPage({hotel,mailboxes,preview,owner,busy,run,onSave}:{hotel:Hot
|
||||
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><section className="settings-card"><div className="section-heading"><Mail size={21}/><div><h2>Connected mailbox</h2><p>Bring recent guest messages into your shared inbox.</p></div></div>{mailboxes.items.map(m=><div className="mailbox" key={m.id}><span className="google-mark">G</span><div><strong>{m.email}</strong><small>{m.syncError||(m.lastSyncAt?'Last synced '+date(m.lastSyncAt):'Waiting for first synchronization')}</small></div><span className="status Completed">{m.status}</span></div>)}{!mailboxes.items.length&&<p className="muted">No mailbox connected yet.</p>}<button className="button secondary" disabled={busy||!mailboxes.configured||!owner} onClick={()=>run(async()=>{const r=await api<{url:string}>('/integrations/google/connect','POST');location.assign(r.url);})}><span className="google-mark">G</span>{mailboxes.items.length?'Connect or reconnect Google':'Connect Google mailbox'}<ArrowUpRight size={16}/></button>{!mailboxes.configured&&<p className="small muted">{preview?'Real mailbox connections are unavailable in this sample workspace.':'Your administrator needs to configure Google connection before this is available.'}</p>}<div className="settings-note"><ShieldCheck size={17}/><span>Sending requires Google permission, the hotel sending control and staff approval of each reply. GuestOps does not delete your Google emails.</span></div></section><ReplyControls hotel={hotel} mailboxes={mailboxes} owner={owner} busy={busy} run={run} onSave={onSave}/></div>;
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<App/>);
|
||||
|
||||
|
||||
@ -11,3 +11,9 @@
|
||||
.reply-actions{padding:18px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}.reply-actions details{padding:12px;background:#f3f5ef;border-radius:10px}.reply-actions details p{white-space:pre-wrap}.reply-actions .form-actions{flex-wrap:wrap;gap:8px}.reply-actions p{line-height:1.6}
|
||||
|
||||
.pms-columns{display:grid;grid-template-columns:1fr 1fr;gap:20px}.pms-page h2{font-size:20px;margin-bottom:18px}.pms-page form{margin-top:18px}.pms-reservation{display:grid;gap:10px;margin:24px 0;padding:18px;background:#f3f6ee;border-radius:10px}.pms-reservation>div{display:grid;grid-template-columns:120px 1fr;gap:12px}.pms-reservation dt{color:#71816b;font-size:13px}.pms-reservation dd{margin:0;overflow-wrap:anywhere;font-size:14px}.pms-history{display:grid;gap:10px;max-height:480px;overflow:auto}.pms-history-item{text-align:left;background:#f7f9f4;border:1px solid var(--border);border-radius:8px;padding:14px;color:#375344}.pms-history-item.selected{border-color:#6c8a54;background:#edf3e7}.pms-history-item span{display:block;font-size:12px;line-height:1.7;margin-top:6px}.pms-review .staff-note{white-space:pre-wrap;overflow-wrap:anywhere}.pms-review .checkbox-label{align-items:flex-start;line-height:1.7}.pms-review .checkbox-label input{flex-shrink:0}.pms-review .form-actions{flex-wrap:wrap}.pms-page .small{overflow-wrap:anywhere}@media(max-width:1100px){.pms-columns{grid-template-columns:1fr}}
|
||||
.team-member{display:flex;justify-content:space-between;gap:20px;padding:22px 0;border-bottom:1px solid var(--line,#e5e8e3)}
|
||||
.team-member p{overflow-wrap:anywhere;margin:7px 0}.team-actions{display:flex;flex-wrap:wrap;gap:8px;align-content:center;justify-content:flex-end}.account-link textarea{overflow-wrap:anywhere}.setup-step{display:flex;gap:20px;align-items:center}.setup-step>div{flex:1}.setup-step h2{margin-top:0}.setup-number{flex-shrink:0;width:36px;height:36px;border-radius:50%;display:grid;place-items:center;background:#f1f1e9;color:#556459;font-weight:700}.setup-number.complete{background:#e0efe5;color:#276448}.account-layout{min-height:100vh;display:grid;place-items:center;padding:30px}.account-layout>section{width:min(100%,520px)}.sidebar nav{overflow-y:auto}.sidebar-bottom{flex-shrink:0}
|
||||
@media(max-width:700px){.team-member{flex-direction:column}.team-actions{justify-content:flex-start}.setup-step{flex-wrap:wrap}.setup-step>div{min-width:160px}.account-layout{padding:16px}}
|
||||
.account-layout h1{margin:28px 0 14px}.account-layout p{margin:12px 0;line-height:1.6}.account-layout form{margin-top:24px}.account-link p{margin:12px 0;line-height:1.6}.team-list{margin-top:12px}
|
||||
@media(min-width:1001px) and (max-height:950px){.sidebar .mode-card{display:none}.sidebar-bottom{padding-top:10px}.profile{margin-top:10px;padding-top:12px}.sidebar{padding-top:20px}.hotel-switch{margin-top:20px;margin-bottom:20px}.nav-item{padding-top:9px;padding-bottom:9px;margin-bottom:4px}}
|
||||
@media(min-width:1001px) and (max-height:800px){.sidebar .nav-label{display:none}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user