User management

This commit is contained in:
2026-03-03 23:15:04 +02:00
parent 667fa25525
commit 2d1923d68d
17 changed files with 1046 additions and 74 deletions

View File

@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace App.Tests;
@@ -164,7 +165,7 @@ public class ProductionAuthTests(ProductionApiTestFactory factory) : IClassFixtu
{
var tokenResponse = await _client.PostAsJsonAsync("/auth/token", new
{
email = "admin@klapi.local",
username = "admin",
password = "changeme"
});
@@ -191,6 +192,54 @@ public class ProductionAuthTests(ProductionApiTestFactory factory) : IClassFixtu
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
}
[Fact]
public async Task UserManagement_Crud_WorksForAdminInProduction()
{
var tokenResponse = await _client.PostAsJsonAsync("/auth/token", new
{
username = "admin",
password = "changeme"
});
Assert.Equal(HttpStatusCode.OK, tokenResponse.StatusCode);
var auth = await tokenResponse.Content.ReadFromJsonAsync<AuthTokenDto>();
Assert.NotNull(auth);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken);
var createResponse = await _client.PostAsJsonAsync("/users", new
{
username = "editor",
password = "editorpass",
isAdmin = false,
displayName = "Editor User"
});
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var usersResponse = await _client.GetAsync("/users");
Assert.Equal(HttpStatusCode.OK, usersResponse.StatusCode);
var users = await usersResponse.Content.ReadFromJsonAsync<List<UserDto>>();
Assert.NotNull(users);
Assert.Contains(users, user => user.Username == "editor");
var updateResponse = await _client.PutAsJsonAsync("/users/editor", new
{
password = "editorpass2",
isAdmin = true,
displayName = "Editor Admin"
});
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updatedUser = await updateResponse.Content.ReadFromJsonAsync<UserDto>();
Assert.NotNull(updatedUser);
Assert.True(updatedUser.IsAdmin);
Assert.Equal("Editor Admin", updatedUser.DisplayName);
var deleteResponse = await _client.DeleteAsync("/users/editor");
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
}
}
public abstract class ApiTestFactoryBase(string environmentName) : WebApplicationFactory<Program>
@@ -207,12 +256,13 @@ public abstract class ApiTestFactoryBase(string environmentName) : WebApplicatio
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = $"Data Source={_dbPath}",
["Auth:Issuer"] = "klapi-api-tests",
["Auth:Audience"] = "klapi-ui-tests",
["Auth:SigningKey"] = "test-signing-key-which-is-at-least-32-characters-long",
["Auth:Issuer"] = "klapi-api",
["Auth:Audience"] = "klapi-ui",
["Auth:SigningKey"] = "change-this-to-a-long-random-32-char-minimum-key",
["Auth:AllowedOrigins:0"] = "http://localhost:5173",
["Auth:Users:0:Email"] = "admin@klapi.local",
["Auth:Users:0:Password"] = "changeme"
["Auth:Admin:Username"] = "admin",
["Auth:Admin:Password"] = "changeme",
["Auth:Admin:DisplayName"] = "Administrator"
});
});
}
@@ -278,9 +328,26 @@ public class AuthTokenDto
{
public string AccessToken { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool IsAdmin { get; set; }
public string TokenType { get; set; } = string.Empty;
public int ExpiresIn { get; set; }
}
public class UserDto
{
public string Username { get; set; } = string.Empty;
public DateTime Added { get; set; }
public DateTime LastUpdated { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty;
}

View File

@@ -4,12 +4,13 @@ using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
public record AuthTokenRequest(string Email, string Password);
public record AuthTokenRequest(string? Username, string Password);
public class AuthUser
public class AuthAdminOptions
{
public string Email { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
}
public class AuthOptions
@@ -18,28 +19,28 @@ public class AuthOptions
public string Audience { get; set; } = "klapi-ui";
public string SigningKey { get; set; } = string.Empty;
public List<string> AllowedOrigins { get; set; } = [];
public List<AuthUser> Users { get; set; } = [];
public AuthAdminOptions Admin { get; set; } = new();
}
public static class AuthEndpoints
{
public static void MapAuthEndpoints(WebApplication app)
{
app.MapPost("/auth/token", (
app.MapPost("/auth/token", async (
HttpContext httpContext,
IOptions<AuthOptions> authOptions,
UserService userService,
AuthTokenRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return Results.BadRequest(new { Message = "Email and password are required." });
return Results.BadRequest(new { Message = "Username and password are required." });
}
var options = authOptions.Value;
var user = options.Users.FirstOrDefault(item =>
string.Equals(item.Email, request.Email.Trim(), StringComparison.OrdinalIgnoreCase));
var authenticatedUser = await userService.Authenticate(request.Username, request.Password);
if (user is null || !string.Equals(user.Password, request.Password, StringComparison.Ordinal))
if (authenticatedUser is null)
{
return Results.Unauthorized();
}
@@ -48,9 +49,11 @@ public static class AuthEndpoints
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Email),
new(JwtRegisteredClaimNames.Email, user.Email),
new(ClaimTypes.Name, user.Email),
new(JwtRegisteredClaimNames.Sub, authenticatedUser.Username),
new(ClaimTypes.Name, authenticatedUser.Username),
new("username", authenticatedUser.Username),
new("display_name", authenticatedUser.DisplayName),
new("is_admin", authenticatedUser.IsAdmin ? "true" : "false"),
new("scope", "openhours:write")
};
@@ -66,7 +69,9 @@ public static class AuthEndpoints
return Results.Ok(new
{
AccessToken = tokenValue,
Email = user.Email,
Username = authenticatedUser.Username,
DisplayName = authenticatedUser.DisplayName,
IsAdmin = authenticatedUser.IsAdmin,
TokenType = "Bearer",
ExpiresIn = 43200
});

View File

@@ -0,0 +1,186 @@
using Microsoft.Data.Sqlite;
public static class UserEndpoints
{
public static void MapUserEndpoints(WebApplication app)
{
app.MapGet("/users", async (HttpContext httpContext) =>
{
var userService = httpContext.RequestServices.GetRequiredService<UserService>();
var users = await userService.GetUsers();
await httpContext.Response.WriteAsJsonAsync(users);
})
.RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly")
.WithName("GetUsers");
app.MapPost("/users", async (HttpContext httpContext) =>
{
var userService = httpContext.RequestServices.GetRequiredService<UserService>();
var request = await httpContext.Request.ReadFromJsonAsync<AppUserCreateRequest>();
if (request is null)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Request body is required."
});
return;
}
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password) || string.IsNullOrWhiteSpace(request.DisplayName))
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Username, password and display name are required."
});
return;
}
try
{
var createdUser = await userService.CreateUser(request);
httpContext.Response.StatusCode = StatusCodes.Status201Created;
httpContext.Response.Headers.Location = $"/users/{createdUser.Username}";
await httpContext.Response.WriteAsJsonAsync(createdUser);
}
catch (SqliteException)
{
httpContext.Response.StatusCode = StatusCodes.Status409Conflict;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User with the same username already exists."
});
}
})
.RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly")
.WithName("CreateUser");
app.MapPut("/users/{username}", async (HttpContext httpContext, string username) =>
{
var userService = httpContext.RequestServices.GetRequiredService<UserService>();
var request = await httpContext.Request.ReadFromJsonAsync<AppUserUpdateRequest>();
if (request is null)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Request body is required."
});
return;
}
if (string.IsNullOrWhiteSpace(request.DisplayName))
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Display name is required."
});
return;
}
var normalizedTargetUsername = username.Trim().ToLowerInvariant();
var existingUser = await userService.GetUser(normalizedTargetUsername);
if (existingUser is null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User not found."
});
return;
}
var adminCount = await userService.GetAdminCount();
if (existingUser.IsAdmin && !request.IsAdmin && adminCount <= 1)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Cannot remove admin role from the last admin user."
});
return;
}
var updatedUser = await userService.UpdateUser(username, request);
if (updatedUser is null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User not found."
});
return;
}
await httpContext.Response.WriteAsJsonAsync(updatedUser);
})
.RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly")
.WithName("UpdateUser");
app.MapDelete("/users/{username}", async (HttpContext httpContext, string username) =>
{
var userService = httpContext.RequestServices.GetRequiredService<UserService>();
var currentUsername = httpContext.User.Identity?.Name?.Trim().ToLowerInvariant() ?? string.Empty;
var normalizedTargetUsername = username.Trim().ToLowerInvariant();
var existingUser = await userService.GetUser(normalizedTargetUsername);
if (existingUser is null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User not found."
});
return;
}
if (currentUsername == normalizedTargetUsername)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "You cannot delete your own user account."
});
return;
}
var adminCount = await userService.GetAdminCount();
if (existingUser.IsAdmin && adminCount <= 1)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Cannot delete the last admin user."
});
return;
}
var deleted = await userService.DeleteUser(username);
if (!deleted)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "User not found."
});
return;
}
httpContext.Response.StatusCode = StatusCodes.Status204NoContent;
})
.RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly")
.WithName("DeleteUser");
}
}

49
api/App/Models/AppUser.cs Normal file
View File

@@ -0,0 +1,49 @@
public class AppUser
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public DateTime Added { get; set; }
public DateTime LastUpdated { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
public class AppUserView
{
public string Username { get; set; } = string.Empty;
public DateTime Added { get; set; }
public DateTime LastUpdated { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
public class AppUserCreateRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
public class AppUserUpdateRequest
{
public string? Password { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty;
}

View File

@@ -30,15 +30,18 @@ public class Program
throw new InvalidOperationException("Auth:SigningKey must be at least 32 characters long.");
}
if (authOptions.Users.Count == 0)
if (string.IsNullOrWhiteSpace(authOptions.Admin.Username)
|| string.IsNullOrWhiteSpace(authOptions.Admin.Password)
|| string.IsNullOrWhiteSpace(authOptions.Admin.DisplayName))
{
throw new InvalidOperationException("At least one user must be configured under Auth:Users.");
throw new InvalidOperationException("Auth:Admin username, password and display name must be configured.");
}
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Auth"));
builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString));
builder.Services.AddScoped<LokService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddCors(options =>
{
options.AddPolicy("PublicReadCors", policy =>
@@ -82,6 +85,12 @@ public class Program
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "openhours:write");
});
options.AddPolicy("AdminOnly", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("is_admin", "true");
});
});
builder.Services.AddOpenApi();
@@ -177,9 +186,20 @@ public class Program
ON LokOpenHours(isActive)
WHERE isActive = 1;";
command.ExecuteNonQuery();
command.CommandText = @"
CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_Username
ON Users(username);";
command.ExecuteNonQuery();
}
}
using (var scope = app.Services.CreateScope())
{
var userService = scope.ServiceProvider.GetRequiredService<UserService>();
userService.EnsureAdminUser(authOptions.Admin).GetAwaiter().GetResult();
}
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
@@ -198,6 +218,7 @@ public class Program
SystemEndpoints.MapSystemEndpoints(app);
AuthEndpoints.MapAuthEndpoints(app);
LokEndpoints.MapLokEndpoints(app);
UserEndpoints.MapUserEndpoints(app);
app.Run();
}

View File

@@ -0,0 +1,277 @@
using System.Data;
using Microsoft.Data.Sqlite;
public class UserService
{
private readonly SqliteConnection _connection;
public UserService(SqliteConnection connection)
{
_connection = connection;
}
public async Task EnsureAdminUser(AuthAdminOptions admin)
{
await EnsureOpenConnection();
var now = DateTime.UtcNow;
await using var command = _connection.CreateCommand();
command.CommandText = @"
INSERT INTO Users (username, password, added, lastUpdated, isAdmin, displayName)
VALUES (@username, @password, @added, @lastUpdated, @isAdmin, @displayName)
ON CONFLICT(username) DO UPDATE SET
password = excluded.password,
lastUpdated = excluded.lastUpdated,
isAdmin = 1,
displayName = excluded.displayName;";
command.Parameters.AddWithValue("@username", admin.Username.Trim().ToLowerInvariant());
command.Parameters.AddWithValue("@password", admin.Password);
command.Parameters.AddWithValue("@added", now.ToString("O"));
command.Parameters.AddWithValue("@lastUpdated", now.ToString("O"));
command.Parameters.AddWithValue("@isAdmin", 1);
command.Parameters.AddWithValue("@displayName", admin.DisplayName.Trim());
await command.ExecuteNonQueryAsync();
}
public async Task<AppUser?> Authenticate(string username, string password)
{
await EnsureOpenConnection();
await using var command = _connection.CreateCommand();
command.CommandText = @"
SELECT id, username, password, added, lastUpdated, isAdmin, displayName
FROM Users
WHERE username = @username
LIMIT 1;";
command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant());
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
{
return null;
}
var user = ReadUser(reader);
if (!string.Equals(user.Password, password, StringComparison.Ordinal))
{
return null;
}
return user;
}
public async Task<List<AppUserView>> GetUsers()
{
await EnsureOpenConnection();
await using var command = _connection.CreateCommand();
command.CommandText = @"
SELECT username, added, lastUpdated, isAdmin, displayName
FROM Users
ORDER BY username ASC;";
await using var reader = await command.ExecuteReaderAsync();
var users = new List<AppUserView>();
while (await reader.ReadAsync())
{
users.Add(new AppUserView
{
Username = reader["username"]?.ToString() ?? string.Empty,
Added = ParseDate(reader["added"]?.ToString()),
LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()),
IsAdmin = ParseBoolean(reader["isAdmin"]),
DisplayName = reader["displayName"]?.ToString() ?? string.Empty
});
}
return users;
}
public async Task<AppUserView> CreateUser(AppUserCreateRequest request)
{
await EnsureOpenConnection();
var now = DateTime.UtcNow;
var normalizedUsername = request.Username.Trim().ToLowerInvariant();
await using var command = _connection.CreateCommand();
command.CommandText = @"
INSERT INTO Users (username, password, added, lastUpdated, isAdmin, displayName)
VALUES (@username, @password, @added, @lastUpdated, @isAdmin, @displayName);";
command.Parameters.AddWithValue("@username", normalizedUsername);
command.Parameters.AddWithValue("@password", request.Password);
command.Parameters.AddWithValue("@added", now.ToString("O"));
command.Parameters.AddWithValue("@lastUpdated", now.ToString("O"));
command.Parameters.AddWithValue("@isAdmin", request.IsAdmin ? 1 : 0);
command.Parameters.AddWithValue("@displayName", request.DisplayName.Trim());
await command.ExecuteNonQueryAsync();
return new AppUserView
{
Username = normalizedUsername,
Added = now,
LastUpdated = now,
IsAdmin = request.IsAdmin,
DisplayName = request.DisplayName.Trim()
};
}
public async Task<AppUserView?> UpdateUser(string username, AppUserUpdateRequest request)
{
await EnsureOpenConnection();
var normalizedUsername = username.Trim().ToLowerInvariant();
var now = DateTime.UtcNow;
var currentUser = await GetUserByUsername(normalizedUsername);
if (currentUser is null)
{
return null;
}
await using var command = _connection.CreateCommand();
command.CommandText = @"
UPDATE Users
SET password = @password,
lastUpdated = @lastUpdated,
isAdmin = @isAdmin,
displayName = @displayName
WHERE username = @username;";
command.Parameters.AddWithValue("@username", normalizedUsername);
command.Parameters.AddWithValue("@password", string.IsNullOrWhiteSpace(request.Password) ? currentUser.Password : request.Password);
command.Parameters.AddWithValue("@lastUpdated", now.ToString("O"));
command.Parameters.AddWithValue("@isAdmin", request.IsAdmin ? 1 : 0);
command.Parameters.AddWithValue("@displayName", request.DisplayName.Trim());
var affectedRows = await command.ExecuteNonQueryAsync();
if (affectedRows == 0)
{
return null;
}
return new AppUserView
{
Username = normalizedUsername,
Added = currentUser.Added,
LastUpdated = now,
IsAdmin = request.IsAdmin,
DisplayName = request.DisplayName.Trim()
};
}
public async Task<bool> DeleteUser(string username)
{
await EnsureOpenConnection();
await using var command = _connection.CreateCommand();
command.CommandText = "DELETE FROM Users WHERE username = @username;";
command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant());
var affectedRows = await command.ExecuteNonQueryAsync();
return affectedRows > 0;
}
public async Task<int> GetAdminCount()
{
await EnsureOpenConnection();
await using var command = _connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM Users WHERE isAdmin = 1;";
return Convert.ToInt32(await command.ExecuteScalarAsync());
}
public async Task<AppUserView?> GetUser(string username)
{
await EnsureOpenConnection();
var user = await GetUserByUsername(username.Trim().ToLowerInvariant());
if (user is null)
{
return null;
}
return new AppUserView
{
Username = user.Username,
Added = user.Added,
LastUpdated = user.LastUpdated,
IsAdmin = user.IsAdmin,
DisplayName = user.DisplayName
};
}
private async Task<AppUser?> GetUserByUsername(string username)
{
await using var command = _connection.CreateCommand();
command.CommandText = @"
SELECT id, username, password, added, lastUpdated, isAdmin, displayName
FROM Users
WHERE username = @username
LIMIT 1;";
command.Parameters.AddWithValue("@username", username);
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
{
return null;
}
return ReadUser(reader);
}
private static AppUser ReadUser(SqliteDataReader reader)
{
return new AppUser
{
Id = reader["id"] is long id ? id : Convert.ToInt64(reader["id"]),
Username = reader["username"]?.ToString() ?? string.Empty,
Password = reader["password"]?.ToString() ?? string.Empty,
Added = ParseDate(reader["added"]?.ToString()),
LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()),
IsAdmin = ParseBoolean(reader["isAdmin"]),
DisplayName = reader["displayName"]?.ToString() ?? string.Empty
};
}
private static DateTime ParseDate(string? value)
{
if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed))
{
return parsed;
}
return DateTime.MinValue;
}
private static bool ParseBoolean(object? value)
{
return value switch
{
bool boolValue => boolValue,
long longValue => longValue == 1,
int intValue => intValue == 1,
string stringValue when int.TryParse(stringValue, out var parsedInt) => parsedInt == 1,
string stringValue when bool.TryParse(stringValue, out var parsedBool) => parsedBool,
_ => false
};
}
private async Task EnsureOpenConnection()
{
if (_connection.State != ConnectionState.Open)
{
await _connection.OpenAsync();
}
}
}

View File

@@ -18,11 +18,10 @@
"http://localhost:4173",
"http://127.0.0.1:4173"
],
"Users": [
{
"Email": "admin@klapi.local",
"Password": "changeme"
}
]
"Admin": {
"Username": "admin",
"Password": "changeme",
"DisplayName": "Administrator"
}
}
}

View File

@@ -18,12 +18,11 @@
"http://localhost:4173",
"http://127.0.0.1:4173"
],
"Users": [
{
"Email": "admin@klapi.local",
"Password": "changeme"
}
]
"Admin": {
"Username": "admin",
"Password": "changeme",
"DisplayName": "Administrator"
}
},
"AllowedHosts": "*"
}

View File

@@ -9,3 +9,13 @@ CREATE TABLE IF NOT EXISTS LokOpenHours (
paragraph4 TEXT NOT NULL DEFAULT '',
kitchenNotice TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS Users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
added TEXT NOT NULL,
lastUpdated TEXT NOT NULL,
isAdmin INTEGER NOT NULL DEFAULT 0,
displayName TEXT NOT NULL DEFAULT ''
);

Binary file not shown.