fix: implement Blazor-native login form to properly update authentication state
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m26s

Problem: JavaScript login form saved tokens to localStorage but didn't notify
CustomAuthenticationStateProvider, causing [Authorize] pages to remain in
'loading' state indefinitely. The provider only reads tokens when:
1. GetAuthenticationStateAsync() is called (page load)
2. NotifyAuthenticationStateChanged() is triggered (UI updates)

But JavaScript login didn't trigger either, leaving the authentication state
stale.

Solution: Convert AdminLoginForm from HTML+JavaScript to pure Blazor component.
Now the login flow is:
1. User enters credentials in Blazor form
2. HttpClient POST to /api/auth/login
3. Save tokens to localStorage
4. Call CustomAuthenticationStateProvider.LoginAsync() directly
5. Blazor detects auth state change and re-evaluates [Authorize] pages
6. Dashboard [Authorize] page renders successfully

Result: Immediate authentication state update, no loading timeout on protected pages.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:03:53 +09:00
parent 041d3cae96
commit 40cffb3beb
326 changed files with 327714 additions and 47 deletions
@@ -0,0 +1,777 @@
var NotesApp = {
currentNoteId: null,
autoSaveTimeout: null,
activeFormatting: {
bold: false,
italic: false,
underline: false
},
init: function() {
this.renderNotesList();
this.setupAutoSave();
this.setupKeyboardShortcuts();
this.setupEditorEvents();
},
setupAutoSave: function() {
var self = this;
document.getElementById('note-content').addEventListener('input', function() {
self.scheduleAutoSave();
self.updateNoteTitle();
});
},
setupEditorEvents: function() {
var self = this;
var editor = document.getElementById('note-content');
// Monitor formatting changes
editor.addEventListener('keyup', function(e) {
self.checkActiveFormatting();
// When Enter is pressed, make sure new paragraph has normal styling
if (e.key === 'Enter') {
self.normalizeNewParagraph();
}
});
editor.addEventListener('click', function() {
self.checkActiveFormatting();
});
// Initial input handling
editor.addEventListener('input', function(e) {
// If this is a new note (empty), format the first line as it's typed
if (editor.childNodes.length === 1 && editor.firstChild.nodeType === Node.TEXT_NODE) {
// Wrap text in a paragraph with heading style
const p = document.createElement('h3');
p.style.fontWeight = 'bold';
p.style.fontSize = '1.2em';
p.style.marginBottom = '0.5em';
p.appendChild(editor.firstChild);
editor.appendChild(p);
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
range.setStart(p.firstChild, p.firstChild.textContent.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
});
},
checkActiveFormatting: function() {
try {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const parent = range.commonAncestorContainer;
// Check for formatting by analyzing the parent elements
this.activeFormatting.bold = this.hasParentWithStyle(parent, 'fontWeight', 'bold') ||
this.hasParentWithTag(parent, 'B') ||
this.hasParentWithTag(parent, 'STRONG');
this.activeFormatting.italic = this.hasParentWithStyle(parent, 'fontStyle', 'italic') ||
this.hasParentWithTag(parent, 'I') ||
this.hasParentWithTag(parent, 'EM');
this.activeFormatting.underline = this.hasParentWithStyle(parent, 'textDecoration', 'underline') ||
this.hasParentWithTag(parent, 'U');
}
} catch (e) {
// Use simpler DOM-based detection instead of queryCommandState
console.warn("Selection-based formatting detection failed:", e);
// Default to inactive formatting
this.activeFormatting.bold = false;
this.activeFormatting.italic = false;
this.activeFormatting.underline = false;
// Try to detect based on current cursor position
try {
const editor = document.getElementById('note-content');
if (editor) {
// Check for formatting by looking at styles around cursor
const selectedNode = document.getSelection().focusNode || editor;
this.activeFormatting.bold = this.isNodeFormatted(selectedNode, 'bold');
this.activeFormatting.italic = this.isNodeFormatted(selectedNode, 'italic');
this.activeFormatting.underline = this.isNodeFormatted(selectedNode, 'underline');
}
} catch (innerError) {
console.error("Fallback formatting detection failed:", innerError);
}
}
// Update UI to reflect active formatting
this.updateFormattingButtons();
},
isNodeFormatted: function(node, format) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
if (node && node.parentNode) {
return this.isNodeFormatted(node.parentNode, format);
}
return false;
}
const element = node;
switch(format) {
case 'bold':
return element.tagName === 'B' ||
element.tagName === 'STRONG' ||
window.getComputedStyle(element).fontWeight === 'bold' ||
parseInt(window.getComputedStyle(element).fontWeight) >= 700;
case 'italic':
return element.tagName === 'I' ||
element.tagName === 'EM' ||
window.getComputedStyle(element).fontStyle === 'italic';
case 'underline':
return element.tagName === 'U' ||
window.getComputedStyle(element).textDecoration.includes('underline');
default:
return false;
}
},
hasParentWithStyle(node, style, value) {
while (node && node.nodeType === 1) {
const computedStyle = window.getComputedStyle(node);
if (computedStyle[style] === value ||
(style === 'textDecoration' && computedStyle[style].includes(value))) {
return true;
}
node = node.parentNode;
}
return false;
},
hasParentWithTag(node, tagName) {
while (node && node.nodeType === 1) {
if (node.tagName === tagName) {
return true;
}
node = node.parentNode;
}
return false;
},
updateFormattingButtons: function() {
document.querySelectorAll('.formatting-btn').forEach(btn => {
const format = btn.getAttribute('data-format');
if (this.activeFormatting[format]) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
},
setupKeyboardShortcuts: function() {
document.addEventListener('keydown', function(e) {
if (!NotesApp.currentNoteId) return;
if (e.ctrlKey) {
switch(e.key.toLowerCase()) {
case 'b':
e.preventDefault();
NotesApp.formatText('bold');
break;
case 'i':
e.preventDefault();
NotesApp.formatText('italic');
break;
case 'u':
e.preventDefault();
NotesApp.formatText('underline');
break;
}
}
});
},
scheduleAutoSave: function() {
var self = this;
clearTimeout(this.autoSaveTimeout);
this.autoSaveTimeout = setTimeout(function() {
self.saveCurrentNote();
}, 500);
},
getAllNotes: function() {
try {
var notes = localStorage.getItem('notes');
return notes ? JSON.parse(notes) : {};
} catch (error) {
console.error('Error reading notes from localStorage:', error);
return {};
}
},
sanitizeHtml: function(html) {
// Check if DOMPurify is available
if (typeof DOMPurify !== 'undefined') {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'u', 'strong', 'em', 'h6', 'p', 'br', 'div', 'span'],
ALLOWED_ATTR: ['style']
});
} else {
// Fallback sanitization - very basic, not as secure
console.warn('DOMPurify not loaded. Using basic sanitization fallback.');
// Create a temporary div to handle the HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// Remove potentially dangerous elements
const scripts = tempDiv.querySelectorAll('script, iframe, object, embed, form, input, button, textarea, style, link, meta');
scripts.forEach(element => element.remove());
// Remove potentially dangerous attributes
const allElements = tempDiv.querySelectorAll('*');
for (let i = 0; i < allElements.length; i++) {
const element = allElements[i];
const attrs = element.attributes;
for (let j = attrs.length - 1; j >= 0; j--) {
const attr = attrs[j];
const attrName = attr.name.toLowerCase();
// Keep only style attribute
if (attrName !== 'style' &&
(attrName.startsWith('on') ||
attrName === 'href' ||
attrName === 'src' ||
attrName === 'srcset' ||
attrName === 'data')) {
element.removeAttribute(attr.name);
}
}
}
return tempDiv.innerHTML;
}
},
extractTitle: function(content) {
if (!content) {
return 'Untitled Note';
}
try {
const div = document.createElement('div');
div.innerHTML = content;
// Look for the first heading or paragraph
const firstElement = div.querySelector('h1, h2, h3, h4, h5, h6, p, div');
if (firstElement) {
const title = firstElement.textContent.trim();
return title.substring(0, 50) + (title.length > 50 ? '...' : '');
}
// Fallback to first line if no elements found
const firstLine = div.textContent.split('\n')[0] || 'Untitled Note';
return firstLine.substring(0, 50) + (firstLine.length > 50 ? '...' : '');
} catch (error) {
console.error('Error extracting title:', error);
return 'Untitled Note';
}
},
updateNoteTitle: function() {
if (!this.currentNoteId) return;
const content = document.getElementById('note-content').innerHTML;
const title = this.extractTitle(content);
var notes = this.getAllNotes();
if (notes[this.currentNoteId]) {
notes[this.currentNoteId].title = title;
localStorage.setItem('notes', JSON.stringify(notes));
this.renderNotesList();
}
},
saveNote: function(noteId, content) {
if (!noteId) {
console.error('Cannot save note: Missing note ID');
return false;
}
// Don't save if content is empty or just the placeholder
const cleanContent = this.cleanContent(content);
if (!cleanContent) {
return false;
}
try {
var notes = this.getAllNotes();
const sanitizedContent = this.sanitizeHtml(content);
notes[noteId] = {
id: noteId,
title: this.extractTitle(sanitizedContent),
content: sanitizedContent,
lastModified: new Date().toISOString()
};
localStorage.setItem('notes', JSON.stringify(notes));
return true;
} catch (error) {
console.error('Error saving note:', error);
return false;
}
},
cleanContent: function(content) {
// Strip all HTML tags to check if there's actual content
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
const textContent = tempDiv.textContent.trim();
return textContent.length > 0 ? content : '';
},
deleteNote: function(noteId) {
if (confirm('Are you sure you want to delete this note?')) {
var notes = this.getAllNotes();
delete notes[noteId];
localStorage.setItem('notes', JSON.stringify(notes));
this.renderNotesList();
}
},
clearAllNotes: function() {
if (confirm('Are you sure you want to delete ALL notes? This cannot be undone.')) {
localStorage.removeItem('notes');
this.renderNotesList();
}
},
createNewNote: function() {
var noteId = 'note_' + new Date().getTime();
this.currentNoteId = noteId;
// Create an empty note first
var notes = this.getAllNotes();
notes[noteId] = {
id: noteId,
title: 'Untitled Note',
content: '',
lastModified: new Date().toISOString()
};
localStorage.setItem('notes', JSON.stringify(notes));
// Now show the editor
this.showNoteEditor(noteId);
},
showNoteEditor: function(noteId) {
var notes = this.getAllNotes();
var note = notes[noteId];
// Safety check to ensure the note exists
if (!note) {
console.error('Note not found:', noteId);
return;
}
document.getElementById('note-content').innerHTML = note.content;
document.getElementById('notes-list-view').style.display = 'none';
document.getElementById('note-edit-view').style.display = 'block';
this.currentNoteId = noteId;
document.getElementById('note-content').focus();
// Ensure the first line is properly formatted as a heading
this.ensureFirstLineIsHeading();
// Check formatting when opening a note
this.checkActiveFormatting();
},
showNotesList: function() {
document.getElementById('notes-list-view').style.display = 'block';
document.getElementById('note-edit-view').style.display = 'none';
this.currentNoteId = null;
this.renderNotesList();
},
saveCurrentNote: function() {
if (!this.currentNoteId) return;
var content = document.getElementById('note-content').innerHTML;
// Get notes and check if currentNoteId exists
var notes = this.getAllNotes();
if (!notes[this.currentNoteId]) {
console.error('Cannot save note: Note ID not found in storage', this.currentNoteId);
return;
}
// Only update the UI if the save was successful (not empty)
if (this.saveNote(this.currentNoteId, content)) {
this.renderNotesList();
}
},
formatText: function(format) {
try {
const editor = document.getElementById('note-content');
const selection = window.getSelection();
if (selection.rangeCount === 0) {
editor.focus();
return;
}
const range = selection.getRangeAt(0);
if (range.collapsed) {
// If no text is selected, don't apply formatting
editor.focus();
return;
}
// Apply the formatting with appropriate HTML elements
let wrapperTag;
switch(format) {
case 'bold':
wrapperTag = document.createElement('strong');
break;
case 'italic':
wrapperTag = document.createElement('em');
break;
case 'underline':
wrapperTag = document.createElement('u');
break;
default:
// For unsupported formats, use alternative method
console.warn(`Format '${format}' is not directly supported`);
editor.focus();
this.scheduleAutoSave();
this.checkActiveFormatting();
return;
}
// Get existing formatting state
const isFormatted = this.activeFormatting[format];
if (isFormatted) {
// If already formatted, remove the formatting by unwrapping content
this.removeFormatting(selection, format);
} else {
// Apply new formatting - surround with appropriate tag
const fragment = range.extractContents();
wrapperTag.appendChild(fragment);
range.insertNode(wrapperTag);
// Preserve selection
selection.removeAllRanges();
const newRange = document.createRange();
newRange.selectNodeContents(wrapperTag);
selection.addRange(newRange);
}
editor.focus();
this.scheduleAutoSave();
this.checkActiveFormatting();
} catch (e) {
// Fallback to simplified approach if error occurs
console.warn("Modern formatting failed:", e);
// Create a temporary error notification
const editor = document.getElementById('note-content');
const notification = document.createElement('div');
notification.textContent = "Formatting could not be applied at this time.";
notification.style.cssText = "position:absolute; top:10px; right:10px; background:#fff3cd; color:#856404; padding:10px; border-radius:5px; z-index:1000;";
document.body.appendChild(notification);
setTimeout(() => {
document.body.removeChild(notification);
}, 3000);
editor.focus();
this.scheduleAutoSave();
this.checkActiveFormatting();
}
},
removeFormatting: function(selection, format) {
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
let targetNodes = [];
// Find all nodes with the specified format within the selection
const findFormattedNodes = (node) => {
if (!node) return;
// Check if this node has the formatting we want to remove
let isFormatted = false;
switch(format) {
case 'bold':
isFormatted = node.nodeName === 'STRONG' || node.nodeName === 'B';
break;
case 'italic':
isFormatted = node.nodeName === 'EM' || node.nodeName === 'I';
break;
case 'underline':
isFormatted = node.nodeName === 'U';
break;
}
if (isFormatted) {
targetNodes.push(node);
return;
}
// If not a match, check children
if (node.hasChildNodes()) {
Array.from(node.childNodes).forEach(child => findFormattedNodes(child));
}
};
// Start from the common ancestor container
findFormattedNodes(range.commonAncestorContainer);
// If we didn't find any direct nodes, look at the selection
if (targetNodes.length === 0) {
const fragment = range.cloneContents();
findFormattedNodes(fragment);
// If we found formatting in the fragment, we need to unwrap in the actual DOM
// We'll use the simpler approach of just replacing with text content
if (targetNodes.length > 0) {
const contentText = range.toString();
range.deleteContents();
range.insertNode(document.createTextNode(contentText));
// Reset selection to the inserted text
selection.removeAllRanges();
selection.addRange(range);
return;
}
}
// Unwrap each formatted node (replace it with its contents)
targetNodes.forEach(node => {
if (node.parentNode) {
while (node.firstChild) {
node.parentNode.insertBefore(node.firstChild, node);
}
node.parentNode.removeChild(node);
}
});
},
renderNotesList: function() {
var notes = this.getAllNotes();
var container = document.getElementById('notes-container');
if (Object.keys(notes).length === 0) {
container.innerHTML = '<p class="text-center text-muted mt-4">No notes yet. Create one by clicking the <u>New Note</u> button!</p>';
return;
}
// Sort notes by date (newest first)
var sortedNoteIds = Object.keys(notes).sort(function(a, b) {
return new Date(notes[b].lastModified) - new Date(notes[a].lastModified);
});
// Group notes by date
var groupedNotes = {};
var today = new Date();
today.setHours(0, 0, 0, 0);
var yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
sortedNoteIds.forEach(function(noteId) {
var note = notes[noteId];
var noteDate = new Date(note.lastModified);
noteDate.setHours(0, 0, 0, 0);
var dateKey;
if (noteDate.getTime() === today.getTime()) {
dateKey = 'Today';
} else if (noteDate.getTime() === yesterday.getTime()) {
dateKey = 'Yesterday';
} else {
dateKey = noteDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
if (!groupedNotes[dateKey]) {
groupedNotes[dateKey] = [];
}
groupedNotes[dateKey].push(note);
});
// Build HTML for grouped notes
var notesHtml = '';
Object.keys(groupedNotes).forEach(function(dateGroup) {
notesHtml += `<h6 class="mt-2 mb-3">${dateGroup}</h6>`;
groupedNotes[dateGroup].forEach(function(note) {
// Create a temporary div to parse the content
var contentDiv = document.createElement('div');
contentDiv.innerHTML = note.content;
var title = '';
var contentText = '';
// Extract title from the first heading or paragraph
var firstElement = contentDiv.querySelector('h1, h2, h3, h4, h5, h6, p, div');
if (firstElement) {
// Use the first element as the title
title = firstElement.textContent.trim();
// Remove the first element from consideration for content
firstElement.remove();
// Get content from the remaining elements
contentText = contentDiv.textContent.trim();
} else {
// Fallback: use the note's title property
title = note.title || 'Untitled Note';
}
// Trim and limit the preview
var preview = contentText.substring(0, 100) + (contentText.length > 100 ? '...' : '');
// If there's no content after the title, show a placeholder preview
if (!preview) {
preview = '<span class="text-muted fst-italic">No additional content</span>';
}
notesHtml += `
<div class="info-container p-3 mb-2 note-item position-relative rounded">
<div class="d-flex w-100" onclick="NotesApp.showNoteEditor('${note.id}')">
<div class="w-100">
<h6 class="mb-1 text-break">${title}</h6>
<p class="mb-0 text-muted preview-text fs-sm text-break">${preview}</p>
</div>
</div>
<button class="btn btn-outline-danger btn-xs position-absolute end-0 top-0 m-2 rounded-pill shadow-0"
onclick="event.stopPropagation(); NotesApp.deleteNote('${note.id}')">
<i class="fal fa-trash"></i>
</button>
</div>`;
});
});
container.innerHTML = notesHtml;
},
normalizeNewParagraph: function() {
const editor = document.getElementById('note-content');
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const currentNode = range.startContainer;
// Find the current paragraph
let currentParagraph = currentNode;
while (currentParagraph && currentParagraph.nodeType !== Node.ELEMENT_NODE) {
currentParagraph = currentParagraph.parentNode;
}
// If this is not the first paragraph, ensure it has normal styling
if (currentParagraph && currentParagraph.previousElementSibling) {
// Remove heading styling if present
currentParagraph.style.fontWeight = 'normal';
currentParagraph.style.fontSize = '1em';
// If it's an H tag, convert to a paragraph
if (currentParagraph.tagName.match(/^H[1-6]$/)) {
const newP = document.createElement('p');
while (currentParagraph.firstChild) {
newP.appendChild(currentParagraph.firstChild);
}
currentParagraph.parentNode.replaceChild(newP, currentParagraph);
// Reset selection to maintain cursor position
const newRange = document.createRange();
newRange.setStart(newP.firstChild || newP, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
}
},
ensureFirstLineIsHeading: function() {
const editor = document.getElementById('note-content');
if (!editor.innerHTML.trim()) {
return;
}
// Look for the first element
const firstElement = editor.querySelector('*');
if (firstElement) {
// If the first line is not already a heading, make it one
if (!(['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(firstElement.tagName))) {
// Convert to proper H3 element
const h3 = document.createElement('h3');
h3.style.fontWeight = 'bold';
h3.style.fontSize = '1.2em';
h3.style.marginBottom = '0.5em';
// Move content to new heading
while (firstElement.firstChild) {
h3.appendChild(firstElement.firstChild);
}
// Replace the element with our heading
firstElement.parentNode.replaceChild(h3, firstElement);
}
// Make sure subsequent elements have normal styling
let nextElement = firstElement.nextElementSibling;
while (nextElement) {
if (!(['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(nextElement.tagName))) {
nextElement.style.fontWeight = 'normal';
nextElement.style.fontSize = '1em';
}
nextElement = nextElement.nextElementSibling;
}
}
}
};
// Global functions for HTML onclick handlers
function createNewNote() {
NotesApp.createNewNote();
}
function showNotesList() {
NotesApp.showNotesList();
}
function formatText(format) {
NotesApp.formatText(format);
}
function clearAllNotes() {
NotesApp.clearAllNotes();
}
// Initialize the app when the page loads
window.onload = function() {
NotesApp.init();
};