Skip to content

Conversation

@PSCourtney
Copy link
Owner

Admin Dashboard and Authentication:

  • Added new admin dashboard, login, and backup management pages with secure authentication using AuthorizeView, including components for admin profile and login handling. [1] [2] [3] [4] [5] [6]
  • Implemented admin login logic with input validation, authentication flow, and error handling in AdminNotLoggedIn.razor.cs.
  • Added admin profile component with logout functionality and retrieval of current admin info.

Backup Management Features:

  • Introduced BackupManagement component and code-behind for listing, creating, downloading, restoring, and deleting database backups, with UI feedback and confirmation dialogs. [1] [2] [3]

DevOps and Configuration:

  • Added a GitHub Actions workflow (release.yaml) to automate publishing of NuGet packages on push to master.
  • Changed the default BaseUrl in PocketBaseOptions to use a local PocketBase instance for easier development and testing.

Bug Fixes and Minor Improvements:

- Introduce admin authentication and dashboard UI in Blazor app
- Implement backup management: create, list, download, restore, delete
- Add BackupService and file token support to PocketBaseSharp SDK
- Update models, services, and extension methods for admin support
- Add JS interop for file downloads; update layout and docs
- Change default PocketBase URL to localhost for dev use
Added GitHub Actions workflow for automatic NuGet publishing on master branch. Updated PocketBaseSharp.csproj with new package metadata, set version to 1.1.0, changed target framework to net10.0, and included icon.png as the package icon. Added icon.png binary file.
Copilot AI review requested due to automatic review settings January 11, 2026 10:20
@PSCourtney PSCourtney merged commit 36d918f into master Jan 11, 2026
6 checks passed
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds backup management capabilities to the PocketBaseSharp SDK and introduces an admin dashboard in the demo application. The changes include new backup service methods, admin authentication updates, and DevOps automation.

Changes:

  • Added comprehensive backup management API (create, download, restore, delete backups) with both sync and async methods
  • Updated admin authentication to use the _superusers collection endpoint and modified the admin model structure
  • Introduced GitHub Actions workflow for automated NuGet package publishing

Reviewed changes

Copilot reviewed 30 out of 32 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
PocketBaseSharp/Services/BackupService.cs Implements complete backup management functionality with detailed XML documentation
PocketBaseSharp/Services/AdminService.cs Updates admin authentication to use _superusers collection endpoint
PocketBaseSharp/PocketBase.cs Adds 204 No Content response handling, file token API, and enhanced stream download support
PocketBaseSharp/Models/* Updates admin and auth models, adds FileTokenResponse, updates BackupModel with DateTimeConverter
PocketBaseSharp/Extensions/PocketBaseExtensions.cs Adds GetCurrentAdmin/GetCurrentAdminAsync extension methods
Example/Pages/Admin/* New admin dashboard with login, profile, and backup management UI components
Example/Services/FileDownloadInterop.cs JavaScript interop service for browser file downloads
Example/Options/PocketBaseOptions.cs Changes default BaseUrl to localhost for local development
.github/workflows/release.yaml Automates NuGet package publishing on master branch pushes
README.md Documents new backup management features and admin dashboard usage

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 13 to +14
[JsonPropertyName("username")]
public string? Username { get; set; }

public string? UserName { get; set; }
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property is named "UserName" (with capital 'N') in the interface but the JSON attribute still uses "username" (lowercase). While this will work for deserialization, the inconsistency between the property name and the interface declaration could cause confusion. Consider whether the interface should use "Username" to match the JSON property name pattern, or if "UserName" is the intended standard.

Copilot uses AI. Check for mistakes.
/// Service for administrating PocketBase superusers (admins).
/// Handles authentication with the _superusers collection endpoint.
/// </summary>
public class AdminService : BaseAuthService<RecordAuthModel<AdminModel>>
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AdminService now uses RecordAuthModel as the generic type parameter and points to the "_superusers" collection endpoint. However, AdminModel now inherits from BaseAuthModel instead of BaseModel. This is a breaking change that could affect existing code that depends on the previous AdminAuthModel type. The PR description doesn't explicitly call out this breaking change, which could cause issues for consumers of the library.

Copilot uses AI. Check for mistakes.
<Description>Multiplatform C# SDK for PocketBase</Description>
<PackageTags>pocketbase;blazor;webassembly;dotnet;sdk;api;realtime;backend;database;authentication</PackageTags>
<Copyright>Copyright (c) 2025 PSCourtney</Copyright>
<PackageReleaseNotes>Version 1.1.0: Added backup management features (create, download, restore, delete backups). Compatible with PocketBase v0.35.1 and .NET 10.</PackageReleaseNotes>
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package version is being set to "1.1.0" but the PackageReleaseNotes mention compatibility with "PocketBase v0.35.1". However, the README.md at line 24 mentions "Pocketbase v0.28.4", and the actual CHANGELOG.md shows v0.35.1 is newer. This inconsistency in version references across documentation should be resolved to avoid confusion about which PocketBase version is actually supported.

Suggested change
<PackageReleaseNotes>Version 1.1.0: Added backup management features (create, download, restore, delete backups). Compatible with PocketBase v0.35.1 and .NET 10.</PackageReleaseNotes>
<PackageReleaseNotes>Version 1.1.0: Added backup management features (create, download, restore, delete backups).</PackageReleaseNotes>

Copilot uses AI. Check for mistakes.
public const string Position = "PocketBase";

public string BaseUrl { get; set; } = "https://sdk-todo-example.pockethost.io/";
public string BaseUrl { get; set; } = "http://127.0.0.1:8090/";
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BaseUrl change from a public hosted instance to localhost (127.0.0.1:8090) will break the demo for users who don't have PocketBase running locally. While this might be intentional for local development, it should be documented in the PR description as a breaking change for the example application, or the value should be configurable via appsettings.json as mentioned in the README at line 65.

Suggested change
public string BaseUrl { get; set; } = "http://127.0.0.1:8090/";
public string BaseUrl { get; set; } = string.Empty;

Copilot uses AI. Check for mistakes.
Comment on lines 33 to +206
public async Task<Result<IEnumerable<BackupModel>>> GetFullListAsync()
{
var b = await _pocketBase.SendAsync<IEnumerable<BackupModel>>("/api/backups", HttpMethod.Get);
return b;
var body = await _pocketBase.SendAsync<IEnumerable<BackupModel>>(BasePath(), HttpMethod.Get);
return body;
}

/// <summary>
/// Triggers a new database backup.
/// </summary>
/// <remarks>
/// Endpoint: POST /api/backups
/// Auth: Admin/Superuser required (via AuthStore)
/// Note: PocketBase returns 204 No Content on success, so the BackupModel in the result will be null.
/// Use GetFullListAsync() after creation to retrieve the backup details.
/// </remarks>
/// <param name="basename">
/// Optional custom filename for the backup (e.g., "daily-backup.zip").
/// If null, PocketBase will auto-generate a timestamp-based name.
/// </param>
/// <param name="body">The request body to send to the API. Default is null.</param>
/// <param name="query">The query parameters to send to the API. Default is null.</param>
/// <param name="headers">The headers to send to the API. Default is null.</param>
/// <param name="cancellationToken">Cancellation token. Default is default.</param>
/// <returns>Result containing null BackupModel (API returns 204 No Content on success).</returns>
public async Task<Result<BackupModel>> CreateAsync(string? basename = null,IDictionary<string, object>? body = null,IDictionary<string, object?>? query = null,IDictionary<string, string>? headers = null,CancellationToken cancellationToken = default)
{
body ??= new Dictionary<string, object>();
if (!string.IsNullOrWhiteSpace(basename))
{
body["basename"] = basename;
}
var url = BasePath();
return await _pocketBase.SendAsync<BackupModel>(url, HttpMethod.Post, headers: headers, query: query, body: body, files: null, cancellationToken: cancellationToken);
}

/// <summary>
/// Triggers a new database backup (synchronous version).
/// </summary>
/// <remarks>
/// Endpoint: POST /api/backups
/// Auth: Admin/Superuser required (via AuthStore)
/// Note: PocketBase returns 204 No Content on success, so the BackupModel in the result will be null.
/// Use GetFullListAsync() after creation to retrieve the backup details.
/// </remarks>
/// <param name="basename">
/// Optional custom filename for the backup (e.g., "daily-backup.zip").
/// If null, PocketBase will auto-generate a timestamp-based name.
/// </param>
/// <param name="body">The request body to send to the API. Default is null.</param>
/// <param name="query">The query parameters to send to the API. Default is null.</param>
/// <param name="headers">The headers to send to the API. Default is null.</param>
/// <param name="cancellationToken">Cancellation token. Default is default.</param>
/// <returns>Result containing null BackupModel (API returns 204 No Content on success).</returns>
public Result<BackupModel> Create(string? basename = null,IDictionary<string, object>? body = null,IDictionary<string, object?>? query = null,IDictionary<string, string>? headers = null,CancellationToken cancellationToken = default)
{
body ??= new Dictionary<string, object>();
if (!string.IsNullOrWhiteSpace(basename))
{
body["basename"] = basename;
}
var url = BasePath();
return _pocketBase.Send<BackupModel>(url, HttpMethod.Post, headers: headers, query: query, body: body, files: null, cancellationToken: cancellationToken);
}

/// <summary>
/// Downloads a backup file as a stream.
/// </summary>
/// <remarks>
/// Endpoint: GET /api/backups/{key}
/// Auth: Admin/Superuser required (via AuthStore)
/// Requires: A valid file token obtained via GetFileTokenAsync()
/// </remarks>
/// <param name="key">The backup key identifier.</param>
/// <param name="query">The query parameters to send to the API. Default is null.</param>
/// <param name="cancellationToken">Cancellation token. Default is default.</param>
/// <returns>Result containing a readable Stream of the backup file.</returns>
public async Task<Result<Stream>> DownloadAsync(string key,IDictionary<string, object?>? query = null,CancellationToken cancellationToken = default)
{
query ??= new Dictionary<string, object?>();

var tokenResult = await _pocketBase.GetFileTokenAsync(cancellationToken);
if (!tokenResult.IsSuccess)
{
return Result.Fail<Stream>(tokenResult.Errors);
}

query["token"] = tokenResult.Value;

var url = $"{BasePath()}/{UrlEncode(key)}";
return await _pocketBase.GetStreamAsync(url, query, cancellationToken);
}


/// <summary>
/// Restores the database from a backup.
/// WARNING: DESTRUCTIVE OPERATION - Completely replaces current database.
/// </summary>
/// <remarks>
/// Endpoint: POST /api/backups/{key}/restore
/// Auth: Admin/Superuser required (via AuthStore)
/// Note: PocketBase will temporarily stop the server, restore, and restart.
/// </remarks>
/// <param name="key">The backup key identifier.</param>
/// <param name="body">The request body to send to the API. Default is null.</param>
/// <param name="query">The query parameters to send to the API. Default is null.</param>
/// <param name="headers">The headers to send to the API. Default is null.</param>
/// <param name="cancellationToken">Cancellation token. Default is default.</param>
/// <returns>Result indicating success/failure of restore operation.</returns>
public async Task<Result> RestoreAsync(string key,IDictionary<string, object>? body = null,IDictionary<string, object?>? query = null,IDictionary<string, string>? headers = null,CancellationToken cancellationToken = default)
{
body ??= new Dictionary<string, object>();
var url = $"{BasePath()}/{UrlEncode(key)}/restore";
return await _pocketBase.SendAsync(url, HttpMethod.Post, headers: headers, query: query, body: body, files: null, cancellationToken: cancellationToken);
}

/// <summary>
/// Restores the database from a backup (synchronous version).
/// WARNING: DESTRUCTIVE OPERATION - Completely replaces current database.
/// </summary>
/// <remarks>
/// Endpoint: POST /api/backups/{key}/restore
/// Auth: Admin/Superuser required (via AuthStore)
/// Note: PocketBase will temporarily stop the server, restore, and restart.
/// </remarks>
/// <param name="key">The backup key identifier.</param>
/// <param name="body">The request body to send to the API. Default is null.</param>
/// <param name="query">The query parameters to send to the API. Default is null.</param>
/// <param name="headers">The headers to send to the API. Default is null.</param>
/// <param name="cancellationToken">Cancellation token. Default is default.</param>
/// <returns>Result indicating success/failure of restore operation.</returns>
public Result Restore(string key,IDictionary<string, object>? body = null,IDictionary<string, object?>? query = null,IDictionary<string, string>? headers = null,CancellationToken cancellationToken = default)
{
body ??= new Dictionary<string, object>();
var url = $"{BasePath()}/{UrlEncode(key)}/restore";
return _pocketBase.Send(url, HttpMethod.Post, headers: headers, query: query, body: body, files: null, cancellationToken: cancellationToken);
}

/// <summary>
/// Deletes a backup file.
/// </summary>
/// <remarks>
/// Endpoint: DELETE /api/backups/{key}
/// Auth: Admin/Superuser required (via AuthStore)
/// </remarks>
/// <param name="key">The backup key identifier.</param>
/// <param name="body">The request body to send to the API. Default is null.</param>
/// <param name="query">The query parameters to send to the API. Default is null.</param>
/// <param name="headers">The headers to send to the API. Default is null.</param>
/// <param name="cancellationToken">Cancellation token. Default is default.</param>
/// <returns>Result indicating success/failure of delete operation.</returns>
public async Task<Result> DeleteAsync(string key,IDictionary<string, object>? body = null,IDictionary<string, object?>? query = null,IDictionary<string, string>? headers = null,CancellationToken cancellationToken = default)
{
var url = $"{BasePath()}/{UrlEncode(key)}";
return await _pocketBase.SendAsync(url, HttpMethod.Delete, headers: headers, query: query, body: body, files: null, cancellationToken: cancellationToken);
}

/// <summary>
/// Deletes a backup file (synchronous version).
/// </summary>
/// <remarks>
/// Endpoint: DELETE /api/backups/{key}
/// Auth: Admin/Superuser required (via AuthStore)
/// </remarks>
/// <param name="key">The backup key identifier.</param>
/// <param name="body">The request body to send to the API. Default is null.</param>
/// <param name="query">The query parameters to send to the API. Default is null.</param>
/// <param name="headers">The headers to send to the API. Default is null.</param>
/// <param name="cancellationToken">Cancellation token. Default is default.</param>
/// <returns>Result indicating success/failure of delete operation.</returns>
public Result Delete(string key,IDictionary<string, object>? body = null,IDictionary<string, object?>? query = null,IDictionary<string, string>? headers = null,CancellationToken cancellationToken = default)
{
var url = $"{BasePath()}/{UrlEncode(key)}";
return _pocketBase.Send(url, HttpMethod.Delete, headers: headers, query: query, body: body, files: null, cancellationToken: cancellationToken);
}
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BackupService introduces significant new functionality (create, download, restore, delete backups) but lacks test coverage. Since the repository has existing tests for other services, test coverage should be added for the new backup management methods to ensure they work correctly and handle edge cases properly.

Copilot uses AI. Check for mistakes.
Comment on lines +347 to +351
if (result.IsSuccess && !string.IsNullOrWhiteSpace(result.Value?.Token))
{
return Result.Ok(result.Value.Token);
}
return Result.Fail("Failed to obtain file token");
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GetFileTokenAsync method lacks error handling for cases where the API response is successful but the token is empty or null. The current implementation returns a generic error message "Failed to obtain file token" which doesn't provide details about what went wrong. Consider adding more specific error information from the result object if available.

Suggested change
if (result.IsSuccess && !string.IsNullOrWhiteSpace(result.Value?.Token))
{
return Result.Ok(result.Value.Token);
}
return Result.Fail("Failed to obtain file token");
if (result.IsFailed)
{
// Preserve detailed errors from the underlying request.
return Result.Fail(result.Errors);
}
if (!string.IsNullOrWhiteSpace(result.Value?.Token))
{
return Result.Ok(result.Value.Token);
}
// Successful response but the token is missing or empty.
return Result.Fail("Failed to obtain file token: the token was null or empty in the response.");

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +104
if (result.IsSuccess)
{
using (var memoryStream = new MemoryStream())
{
await result.Value.CopyToAsync(memoryStream);
var bytes = memoryStream.ToArray();

await _fileDownloadInterop!.DownloadFileAsync(bytes, key);
Snackbar.Add($"Backup downloaded: {key} ({bytes.Length} bytes)", Severity.Success);
}
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading the entire backup file into memory via memoryStream.ToArray() could cause memory issues for large database backups. Consider streaming the download directly to the browser or implementing chunked downloads for better memory efficiency, especially in WASM environments where memory is limited.

Suggested change
if (result.IsSuccess)
{
using (var memoryStream = new MemoryStream())
{
await result.Value.CopyToAsync(memoryStream);
var bytes = memoryStream.ToArray();
await _fileDownloadInterop!.DownloadFileAsync(bytes, key);
Snackbar.Add($"Backup downloaded: {key} ({bytes.Length} bytes)", Severity.Success);
}
if (result.IsSuccess && result.Value is not null)
{
await using var streamRef = new DotNetStreamReference(result.Value);
await JSRuntime.InvokeVoidAsync("downloadFileFromStream", key, streamRef);
Snackbar.Add($"Backup downloaded: {key}", Severity.Success);

Copilot uses AI. Check for mistakes.
try
{
//Warning: This does not work on pocketbase windows hosted. See: https://github.com/pocketbase/pocketbase/issues/3411
//It retrns a 204 but the logs will show restore is not supported on Windows
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment mentions "retrns" which is a typo. It should be "returns".

Suggested change
//It retrns a 204 but the logs will show restore is not supported on Windows
//It returns a 204 but the logs will show restore is not supported on Windows

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +104
var result = await PocketBase.Backup.DownloadAsync(key);
if (result.IsSuccess)
{
using (var memoryStream = new MemoryStream())
{
await result.Value.CopyToAsync(memoryStream);
var bytes = memoryStream.ToArray();

await _fileDownloadInterop!.DownloadFileAsync(bytes, key);
Snackbar.Add($"Backup downloaded: {key} ({bytes.Length} bytes)", Severity.Success);
}
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Stream returned by DownloadAsync is not being disposed in the DownloadBackupAsync method. After copying to memoryStream, the original stream should be disposed to prevent resource leaks. Consider wrapping result.Value in a using statement.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants