Serve Google calendar app config from Jarvis
This commit is contained in:
parent
ff677dc870
commit
3de2fa714f
@ -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<WorkspaceState> GetOrCreateWorkspace(
|
||||
return workspace;
|
||||
}
|
||||
|
||||
static async Task<string?> 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<RegisteredDevice> devices)
|
||||
<p class="eyebrow">JARVIS API</p>
|
||||
<h1>Registered devices</h1>
|
||||
</div>
|
||||
<form method="post" action="/logout"><button class="secondary">Sign out</button></form>
|
||||
<div class="actions">
|
||||
<a class="button secondary" href="/settings">Settings</a>
|
||||
<form method="post" action="/logout"><button class="secondary">Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">The app can submit a device request through <code>POST /api/devices/register</code>. New devices stay pending until approved here.</p>
|
||||
<table>
|
||||
@ -933,8 +1011,46 @@ static string DevicesPage(IReadOnlyList<RegisteredDevice> devices)
|
||||
""";
|
||||
}
|
||||
|
||||
static string SettingsPage(string googleClientId) => $$"""
|
||||
<!doctype html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>JARVIS API settings</title>
|
||||
<style>{{AdminCss()}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="top">
|
||||
<div>
|
||||
<p class="eyebrow">JARVIS API</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="button secondary" href="/devices">Devices</a>
|
||||
<form method="post" action="/logout"><button class="secondary">Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Google Calendar OAuth</h2>
|
||||
<p class="hint">Set the <strong>Web application OAuth Client ID</strong> from Google Cloud Console. Tablets will fetch this automatically; family members only see the Google consent screen.</p>
|
||||
<form method="post" action="/settings/google-calendar">
|
||||
<label>Server client ID</label>
|
||||
<input name="serverClientId" value="{{Html(googleClientId)}}" placeholder="1234567890-xxxx.apps.googleusercontent.com">
|
||||
<button>Save Google Calendar settings</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
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<OtthonDbContext> options)
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>();
|
||||
public DbSet<WorkspaceState> Workspaces => Set<WorkspaceState>();
|
||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<AppSetting>().HasKey(setting => setting.Key);
|
||||
modelBuilder.Entity<AppSetting>().Property(setting => setting.Key).HasMaxLength(160);
|
||||
modelBuilder.Entity<WorkspaceState>().HasKey(workspace => workspace.Token);
|
||||
modelBuilder.Entity<WorkspaceState>().Property(workspace => workspace.Token).HasMaxLength(160);
|
||||
modelBuilder.Entity<Product>().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,
|
||||
|
||||
@ -351,6 +351,7 @@ class _HomeShellState extends State<HomeShell> {
|
||||
connections: widget.googleConnections,
|
||||
cachedEvents: widget.syncedEvents,
|
||||
people: widget.people,
|
||||
jarvisSettings: widget.jarvisSettings,
|
||||
onChanged: widget.onGoogleConnectionsChanged,
|
||||
onSyncedEventsChanged: widget.onSyncedEventsChanged,
|
||||
),
|
||||
|
||||
@ -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<GoogleCalendarConnection> connections;
|
||||
final List<CalendarEvent> cachedEvents;
|
||||
final List<Person> people;
|
||||
final JarvisSettings jarvisSettings;
|
||||
final ValueChanged<List<GoogleCalendarConnection>> onChanged;
|
||||
final ValueChanged<List<CalendarEvent>> onSyncedEventsChanged;
|
||||
|
||||
@ -28,6 +30,7 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||
final googleSignIn = GoogleSignIn.instance;
|
||||
List<GoogleCalendarInfo> googleCalendars = [];
|
||||
GoogleSignInAccount? signedInAccount;
|
||||
String runtimeServerClientId = googleCalendarServerClientId;
|
||||
bool initialized = false;
|
||||
bool loadingCalendars = false;
|
||||
bool syncing = false;
|
||||
@ -36,17 +39,62 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||
@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<void> _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<String?> _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<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
return payload['googleCalendarServerClientId'] as String?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeGoogle() async {
|
||||
setState(() {
|
||||
loadingCalendars = true;
|
||||
@ -121,10 +169,10 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||
}
|
||||
|
||||
Future<void> _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.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user