diff --git a/api/OtthonApi/Program.cs b/api/OtthonApi/Program.cs index 92dbfcc..b6a0dfb 100644 --- a/api/OtthonApi/Program.cs +++ b/api/OtthonApi/Program.cs @@ -121,6 +121,36 @@ app.MapPost("/devices/{id:int}/approve", async (int id, OtthonDbContext db) => return Results.Redirect("/devices"); }).RequireAuthorization().DisableAntiforgery(); +app.MapGet("/settings", async (OtthonDbContext db) => +{ + var googleClientId = await GetSetting(db, GoogleCalendarServerClientIdSettingKey()) ?? ""; + return Results.Content(SettingsPage(googleClientId), "text/html; charset=utf-8"); +}).RequireAuthorization(); + +app.MapPost("/settings/google-calendar", async ( + [FromForm] GoogleCalendarSettingsForm form, + OtthonDbContext db) => +{ + await SetSetting( + db, + GoogleCalendarServerClientIdSettingKey(), + Clean(form.ServerClientId) ?? ""); + return Results.Redirect("/settings"); +}).RequireAuthorization().DisableAntiforgery(); + +app.MapGet("/api/app-config", async (HttpContext context, OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var googleClientId = await GetSetting(db, GoogleCalendarServerClientIdSettingKey()); + return Results.Ok(new AppConfigResponse( + googleClientId, + !string.IsNullOrWhiteSpace(googleClientId))); +}); + app.MapPost("/api/devices/register", async ( DeviceRegistrationRequest request, HttpContext context, @@ -603,6 +633,34 @@ static async Task GetOrCreateWorkspace( return workspace; } +static async Task GetSetting(OtthonDbContext db, string key) +{ + var setting = await db.AppSettings.FindAsync(key); + return Clean(setting?.Value); +} + +static async Task SetSetting(OtthonDbContext db, string key, string value) +{ + var setting = await db.AppSettings.FindAsync(key); + if (setting is null) + { + setting = new AppSetting + { + Key = key, + Value = value, + UpdatedUtc = DateTimeOffset.UtcNow + }; + db.AppSettings.Add(setting); + } + else + { + setting.Value = value; + setting.UpdatedUtc = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(); +} + static void ApplyProductLookup(Product target, Product lookup, string source) { target.Name = lookup.Name; @@ -688,6 +746,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await EnsureProductSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db); await EnsureWorkspaceSchemaAsync(db); + await EnsureAppSettingsSchemaAsync(db); return; } catch (Exception ex) when (attempt < maxAttempts) @@ -705,6 +764,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await EnsureProductSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db); await EnsureWorkspaceSchemaAsync(db); + await EnsureAppSettingsSchemaAsync(db); } static async Task EnsureProductSchemaAsync(OtthonDbContext db) @@ -740,6 +800,21 @@ END await db.Database.ExecuteSqlRawAsync(sql); } +static async Task EnsureAppSettingsSchemaAsync(OtthonDbContext db) +{ + var sql = """ +IF OBJECT_ID('AppSettings', 'U') IS NULL +BEGIN + CREATE TABLE AppSettings ( + [Key] nvarchar(160) NOT NULL CONSTRAINT PK_AppSettings PRIMARY KEY, + [Value] nvarchar(max) NOT NULL, + UpdatedUtc datetimeoffset NOT NULL + ); +END +"""; + await db.Database.ExecuteSqlRawAsync(sql); +} + static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db) { var sql = """ @@ -910,7 +985,10 @@ static string DevicesPage(IReadOnlyList devices)

JARVIS API

Registered devices

-
+
+ Settings +
+

The app can submit a device request through POST /api/devices/register. New devices stay pending until approved here.

@@ -933,8 +1011,46 @@ static string DevicesPage(IReadOnlyList devices) """; } +static string SettingsPage(string googleClientId) => $$""" + + + + + + JARVIS API settings + + + +
+
+
+

JARVIS API

+

Settings

+
+
+ Devices +
+
+
+ +
+

Google Calendar OAuth

+

Set the Web application OAuth Client ID from Google Cloud Console. Tablets will fetch this automatically; family members only see the Google consent screen.

+
+ + + + +
+
+ + +"""; + static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? ""); +static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId"; + static string AdminCss() => """ :root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; } body { margin: 0; min-height: 100vh; background: #f4f1ea; color: #17342d; display: grid; place-items: center; } @@ -946,11 +1062,16 @@ h1 { margin: 0 0 18px; font-size: clamp(28px, 4vw, 44px); } p { color: #5b6d67; } label { display: block; margin-bottom: 8px; font-weight: 800; } input { width: 100%; box-sizing: border-box; border: 1px solid #d9e2de; border-radius: 16px; padding: 14px; font-size: 16px; } -button { border: 0; border-radius: 999px; padding: 12px 18px; background: #0f7b61; color: white; font-weight: 900; cursor: pointer; margin-top: 14px; } +button, .button { border: 0; border-radius: 999px; padding: 12px 18px; background: #0f7b61; color: white; font-weight: 900; cursor: pointer; margin-top: 14px; text-decoration: none; display: inline-block; } button.secondary { background: #e6f1ed; color: #17342d; margin-top: 0; } +.button.secondary { background: #e6f1ed; color: #17342d; margin-top: 0; } button.approve { background: #0f7b61; color: white; margin-top: 0; margin-right: 8px; } button:disabled { opacity: .35; cursor: not-allowed; } td form { display: inline-block; margin: 0; } +.actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.actions form { margin: 0; } +.panel { margin-top: 20px; padding: 20px; border: 1px solid #edf2ef; border-radius: 22px; } +.panel h2 { margin-top: 0; } .error { background: #ffe8e0; color: #9b341f; padding: 12px 14px; border-radius: 14px; margin-bottom: 14px; } .hint { background: #e6f1ed; padding: 14px 16px; border-radius: 18px; } table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 18px; } @@ -969,9 +1090,12 @@ public sealed class OtthonDbContext(DbContextOptions options) public DbSet Products => Set(); public DbSet FoodLearningEvents => Set(); public DbSet Workspaces => Set(); + public DbSet AppSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity().HasKey(setting => setting.Key); + modelBuilder.Entity().Property(setting => setting.Key).HasMaxLength(160); modelBuilder.Entity().HasKey(workspace => workspace.Token); modelBuilder.Entity().Property(workspace => workspace.Token).HasMaxLength(160); modelBuilder.Entity().HasKey(product => product.Barcode); @@ -1046,7 +1170,15 @@ public sealed class WorkspaceState public DateTimeOffset UpdatedUtc { get; set; } } +public sealed class AppSetting +{ + public required string Key { get; set; } + public required string Value { get; set; } + public DateTimeOffset UpdatedUtc { get; set; } +} + public sealed record LoginForm(string Password); +public sealed record GoogleCalendarSettingsForm(string? ServerClientId); public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform); public sealed record ProductUpsertRequest( string Barcode, @@ -1116,6 +1248,10 @@ public sealed record WorkspaceStateResponse( string StateJson, DateTimeOffset UpdatedUtc); +public sealed record AppConfigResponse( + string? GoogleCalendarServerClientId, + bool GoogleCalendarConfigured); + public sealed record ProductResponse( bool Found, string Barcode, diff --git a/lib/app_shell.dart b/lib/app_shell.dart index a75cb7c..4644b04 100644 --- a/lib/app_shell.dart +++ b/lib/app_shell.dart @@ -351,6 +351,7 @@ class _HomeShellState extends State { connections: widget.googleConnections, cachedEvents: widget.syncedEvents, people: widget.people, + jarvisSettings: widget.jarvisSettings, onChanged: widget.onGoogleConnectionsChanged, onSyncedEventsChanged: widget.onSyncedEventsChanged, ), diff --git a/lib/google_calendar_page.dart b/lib/google_calendar_page.dart index 4377f13..5e74609 100644 --- a/lib/google_calendar_page.dart +++ b/lib/google_calendar_page.dart @@ -6,6 +6,7 @@ class AccountsPage extends StatefulWidget { required this.connections, required this.cachedEvents, required this.people, + required this.jarvisSettings, required this.onChanged, required this.onSyncedEventsChanged, }); @@ -13,6 +14,7 @@ class AccountsPage extends StatefulWidget { final List connections; final List cachedEvents; final List people; + final JarvisSettings jarvisSettings; final ValueChanged> onChanged; final ValueChanged> onSyncedEventsChanged; @@ -28,6 +30,7 @@ class _AccountsPageState extends State { final googleSignIn = GoogleSignIn.instance; List googleCalendars = []; GoogleSignInAccount? signedInAccount; + String runtimeServerClientId = googleCalendarServerClientId; bool initialized = false; bool loadingCalendars = false; bool syncing = false; @@ -36,17 +39,62 @@ class _AccountsPageState extends State { @override void initState() { super.initState(); - if (googleCalendarServerClientId.trim().isEmpty) { - statusMessage = - 'A Google napt\u00e1rkapcsolat m\u00e9g nincs bek\u00f6tve ebbe az appverzi\u00f3ba. A csal\u00e1dtagoknak nem kell client ID-t megadniuk; ezt egyszer, fejleszt\u0151i be\u00e1ll\u00edt\u00e1sk\u00e9nt kell az apphoz rendelni.'; - } else { - _initializeGoogle(); - } + _loadGoogleAppConfigAndInitialize(); } GoogleCalendarConnection? get connection => widget.connections.isEmpty ? null : widget.connections.first; + Future _loadGoogleAppConfigAndInitialize() async { + setState(() { + loadingCalendars = true; + statusMessage = 'Google napt\u00e1rkapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...'; + }); + + final configuredClientId = await _loadGoogleServerClientId(); + if (!mounted) return; + if (configuredClientId == null || configuredClientId.trim().isEmpty) { + setState(() { + loadingCalendars = false; + statusMessage = + 'A Google napt\u00e1rkapcsolat m\u00e9g nincs bek\u00f6tve. A csal\u00e1dtagoknak nem kell client ID-t megadniuk; a JARVIS admin fel\u00fcleten kell egyszer be\u00e1ll\u00edtani a Google Calendar OAuth azonos\u00edt\u00f3t.'; + }); + return; + } + + runtimeServerClientId = configuredClientId.trim(); + await _initializeGoogle(); + } + + Future _loadGoogleServerClientId() async { + if (googleCalendarServerClientId.trim().isNotEmpty) { + return googleCalendarServerClientId.trim(); + } + + final apiToken = widget.jarvisSettings.apiToken.trim(); + if (apiToken.isEmpty) { + return null; + } + + try { + final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl)); + final uri = baseUri.replace(path: '/api/app-config', query: ''); + final response = await http + .get(uri, headers: {'X-Otthon-Token': apiToken}) + .timeout(const Duration(seconds: 10)); + if (response.statusCode != 200) { + return null; + } + final payload = jsonDecode(response.body); + if (payload is! Map) { + return null; + } + return payload['googleCalendarServerClientId'] as String?; + } catch (_) { + return null; + } + } + Future _initializeGoogle() async { setState(() { loadingCalendars = true; @@ -121,10 +169,10 @@ class _AccountsPageState extends State { } Future _ensureGoogleInitialized() async { - final clientId = googleCalendarServerClientId.trim(); + final clientId = runtimeServerClientId.trim(); if (clientId.isEmpty) { throw Exception( - 'A Google napt\u00e1rkapcsolat m\u00e9g nincs bek\u00f6tve ebbe az appverzi\u00f3ba.', + 'A Google napt\u00e1rkapcsolat m\u00e9g nincs bek\u00f6tve.', ); }