Polish UI, harden company creation, and add error pages

This commit is contained in:
cesnimda
2026-03-23 19:34:29 +01:00
parent 8f5eab2fe4
commit fcafda6f52
38 changed files with 2293 additions and 1269 deletions
@@ -57,6 +57,7 @@ public sealed class AuthController : ControllerBase
string? LastName,
string? DisplayName,
string? ProfileCvText,
string? AvatarImageDataUrl,
IList<string> Roles,
GoogleLinkDto? GoogleLink);
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText);
@@ -172,6 +173,7 @@ public sealed class AuthController : ControllerBase
LastName: User.FindFirstValue("family_name"),
DisplayName: User.FindFirstValue("name"),
ProfileCvText: null,
AvatarImageDataUrl: null,
Roles: Array.Empty<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
}
@@ -277,6 +279,70 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
[HttpPost("avatar")]
[Authorize(AuthenticationSchemes = "local")]
[RequestSizeLimit(5_000_000)]
public async Task<IActionResult> UploadAvatar([FromForm] IFormFile? file)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
if (file is null || file.Length == 0)
{
return BadRequest("Image file is required.");
}
if (!string.Equals(file.ContentType, "image/png", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(file.ContentType, "image/webp", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
}
if (file.Length > 5_000_000)
{
return BadRequest("Avatar image is too large.");
}
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
var bytes = memory.ToArray();
var base64 = Convert.ToBase64String(bytes);
user.AvatarImageDataUrl = $"data:{file.ContentType};base64,{base64}";
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new { avatarImageDataUrl = user.AvatarImageDataUrl });
}
[HttpDelete("avatar")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> DeleteAvatar()
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
user.AvatarImageDataUrl = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return NoContent();
}
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
[HttpPost("change-password")]
@@ -374,6 +440,7 @@ public sealed class AuthController : ControllerBase
LastName: user.LastName,
DisplayName: user.DisplayName,
ProfileCvText: user.ProfileCvText,
AvatarImageDataUrl: user.AvatarImageDataUrl,
Roles: roles,
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
@@ -1,5 +1,4 @@
// <auto-generated />
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -15,301 +14,7 @@ namespace JobTrackerApi.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.17");
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Source")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UploadDate")
.HasColumnType("TEXT");
b.Property<string>("FileType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Attachments");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("Correspondences");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CompanyId")
.HasColumnType("INTEGER");
b.Property<string>("CoverLetterText")
.HasColumnType("TEXT");
b.Property<DateTime>("DateApplied")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("Deadline")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("DescriptionLanguage")
.HasColumnType("TEXT");
b.Property<DateTime?>("FeedbackRequestedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("FollowUpAt")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("JobTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("JobUrl")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("NextAction")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResponseDate")
.HasColumnType("TEXT");
b.Property<bool>("ResponseReceived")
.HasColumnType("INTEGER");
b.Property<string>("Salary")
.HasColumnType("TEXT");
b.Property<string>("ShortSummary")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("Applied");
b.Property<string>("Tags")
.HasColumnType("TEXT");
b.Property<string>("TranslatedDescription")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("JobApplications");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OldValue")
.HasColumnType("TEXT");
b.Property<string>("NewValue")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<DateTime>("At")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.ToTable("JobEvents");
});
modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<int>("AppliedFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("AppliedGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("OfferGhostDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackFollowUpDays")
.HasColumnType("INTEGER");
b.Property<int>("FeedbackGhostDays")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("RuleSettings");
b.HasData(new
{
Id = 1,
AppliedFollowUpDays = 14,
AppliedGhostDays = 30,
OfferFollowUpDays = 7,
OfferGhostDays = 14,
FeedbackFollowUpDays = 7,
FeedbackGhostDays = 14
});
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Messages")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Attachment", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Attachments")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.HasOne("JobTrackerApi.Models.Company", "Company")
.WithMany("Jobs")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Company");
});
modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany("Events")
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.Company", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b =>
{
b.Navigation("Attachments");
b.Navigation("Events");
b.Navigation("Messages");
});
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
#pragma warning restore 612, 618
}
}
+59
View File
@@ -347,6 +347,7 @@ CREATE TABLE IF NOT EXISTS `AspNetUsers` (
`LastName` longtext NULL,
`DisplayName` longtext NULL,
`ProfileCvText` longtext NULL,
`AvatarImageDataUrl` longtext NULL,
`GoogleSubject` longtext NULL,
`GoogleEmail` longtext NULL,
`GoogleLinkedAt` datetime(6) NULL,
@@ -498,6 +499,8 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" (
"FirstName" TEXT NULL,
"LastName" TEXT NULL,
"DisplayName" TEXT NULL,
"ProfileCvText" TEXT NULL,
"AvatarImageDataUrl" TEXT NULL,
"GoogleSubject" TEXT NULL,
"GoogleEmail" TEXT NULL,
"GoogleLinkedAt" TEXT NULL
@@ -570,6 +573,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE AspNetUsers ADD COLUMN AvatarImageDataUrl TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
@@ -680,6 +684,61 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
using var conn = new MySqlConnection(cs);
conn.Open();
EnsureIdentityTablesMySql(conn);
static bool MySqlColumnExists(MySqlConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
cmd.Parameters.AddWithValue("@schema", c.Database);
cmd.Parameters.AddWithValue("@table", table);
cmd.Parameters.AddWithValue("@column", column);
return cmd.ExecuteScalar() is not null;
}
static bool MySqlIndexExists(MySqlConnection c, string table, string indexName)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;";
cmd.Parameters.AddWithValue("@schema", c.Database);
cmd.Parameters.AddWithValue("@table", table);
cmd.Parameters.AddWithValue("@index", indexName);
return cmd.ExecuteScalar() is not null;
}
static void EnsureMySqlColumn(MySqlConnection c, string table, string column, string ddl)
{
using var existsCmd = c.CreateCommand();
existsCmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
existsCmd.Parameters.AddWithValue("@schema", c.Database);
existsCmd.Parameters.AddWithValue("@table", table);
if (existsCmd.ExecuteScalar() is null) return;
if (MySqlColumnExists(c, table, column)) return;
using var ddlCmd = c.CreateCommand();
ddlCmd.CommandText = ddl;
ddlCmd.ExecuteNonQuery();
}
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
}
}
+43 -43
View File
@@ -1,44 +1,44 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Cors": {
"Origins": [
"http://localhost:3000",
"https://jobs.cesnimda.uk"
]
},
"Exports": {
"DailyEnabled": true,
"DailyFolder": "exports",
"DailyHourLocal": 2
},
"Auth": {
"Require": true,
"AllowRegistration": false,
"JwtKey": "Y00VuqZehhsMiNa8elch7q7FOlPm5ncugKJtMOpFn3P2xNtrZVfvGxVP2bKbnzL6rI08/H6vZGNBYh1dHh71/g==",
"JwtIssuer": "JobTrackerApi",
"JwtAudience": "job-tracker-ui",
"JwtExpiresMinutes": 720,
"AdminEmail": "dj@cesnimda.co.uk",
"AdminPassword": "Leethacks12",
"GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com"
},
"App": {
"PublicBaseUrl": "https://jobs.cesnimda.uk"
},
"Email": {
"Enabled": false,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUser": "CHANGE_ME_GMAIL_ADDRESS",
"SmtpPassword": "CHANGE_ME_GOOGLE_APP_PASSWORD",
"From": "CHANGE_ME_GMAIL_ADDRESS",
"FromName": "Job Tracker",
"SmtpEnableSsl": true,
"SmtpTimeoutMs": 15000
}
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Cors": {
"Origins": [
"http://localhost:3000",
"https://jobs.cesnimda.uk"
]
},
"Exports": {
"DailyEnabled": true,
"DailyFolder": "exports",
"DailyHourLocal": 2
},
"Auth": {
"Require": true,
"AllowRegistration": false,
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
"JwtIssuer": "JobTrackerApi",
"JwtAudience": "job-tracker-ui",
"JwtExpiresMinutes": 720,
"AdminEmail": "admin@example.com",
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID"
},
"App": {
"PublicBaseUrl": "https://jobs.cesnimda.uk"
},
"Email": {
"Enabled": false,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUser": "CHANGE_ME_GMAIL_ADDRESS",
"SmtpPassword": "CHANGE_ME_GOOGLE_APP_PASSWORD",
"From": "CHANGE_ME_GMAIL_ADDRESS",
"FromName": "Jobbjakt",
"SmtpEnableSsl": true,
"SmtpTimeoutMs": 15000
}
}