fix: resolve folder drag-and-drop, add remember me, fix pinned folder navigation
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m18s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m18s
This commit is contained in:
parent
7278008d7e
commit
b668418951
5 changed files with 89 additions and 14 deletions
|
|
@ -43,6 +43,7 @@ type LoginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
TOTPCode string `json:"totp_code,omitempty"`
|
TOTPCode string `json:"totp_code,omitempty"`
|
||||||
|
RememberMe bool `json:"remember_me"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup handles the first-run account creation wizard.
|
// Setup handles the first-run account creation wizard.
|
||||||
|
|
@ -150,7 +151,7 @@ func (h *AuthHandler) Login(c *fiber.Ctx) error {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshToken, err := middleware.GenerateRefreshToken(id, req.Username, h.Config.JWTSecret)
|
refreshToken, err := middleware.GenerateRefreshToken(id, req.Username, h.Config.JWTSecret, req.RememberMe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate refresh token"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate refresh token"})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,11 +86,16 @@ func GenerateAccessToken(userID int, username, secret string, expirySecs int) (s
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateRefreshToken creates a longer-lived refresh token.
|
// GenerateRefreshToken creates a longer-lived refresh token.
|
||||||
func GenerateRefreshToken(userID int, username, secret string) (string, error) {
|
func GenerateRefreshToken(userID int, username, secret string, rememberMe bool) (string, error) {
|
||||||
|
exp := time.Now().Add(7 * 24 * time.Hour).Unix() // 7 days
|
||||||
|
if rememberMe {
|
||||||
|
exp = time.Now().Add(21 * 24 * time.Hour).Unix() // 21 days (3 weeks)
|
||||||
|
}
|
||||||
|
|
||||||
claims := jwt.MapClaims{
|
claims := jwt.MapClaims{
|
||||||
"sub": userID,
|
"sub": userID,
|
||||||
"username": username,
|
"username": username,
|
||||||
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(), // 7 days
|
"exp": exp,
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
|
|
||||||
if (section === 'files') {
|
if (section === 'files') {
|
||||||
navigateTo('.');
|
navigateTo('.');
|
||||||
loadFiles();
|
loadFiles('.');
|
||||||
} else if (section === 'trash') {
|
} else if (section === 'trash') {
|
||||||
loadTrash();
|
loadTrash();
|
||||||
}
|
}
|
||||||
|
|
@ -748,11 +748,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFile(file: File) {
|
async function uploadFile(file: File, targetDir?: string) {
|
||||||
const id = `${Date.now()}-${file.name}`;
|
const id = `${Date.now()}-${file.name}`;
|
||||||
setUploads(prev => [...prev, { id, name: file.name, progress: 0, status: 'uploading' }]);
|
setUploads(prev => [...prev, { id, name: file.name, progress: 0, status: 'uploading' }]);
|
||||||
try {
|
try {
|
||||||
await api.upload(file, currentPath, {
|
const uploadPath = targetDir ? (currentPath === '.' ? targetDir : `${currentPath}/${targetDir}`) : currentPath;
|
||||||
|
await api.upload(file, uploadPath, {
|
||||||
onProgress: (percent) => {
|
onProgress: (percent) => {
|
||||||
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: percent } : u));
|
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: percent } : u));
|
||||||
},
|
},
|
||||||
|
|
@ -770,8 +771,56 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
if (draggedFile) return;
|
if (draggedFile) return;
|
||||||
|
|
||||||
|
if (e.dataTransfer.items) {
|
||||||
|
const filesToUpload: { file: File; path: string }[] = [];
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
const processEntry = async (entry: any, path: string = '') => {
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve) => entry.file(resolve));
|
||||||
|
filesToUpload.push({ file, path });
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
const dirReader = entry.createReader();
|
||||||
|
let entries: any[] = [];
|
||||||
|
|
||||||
|
const readAllEntries = async () => {
|
||||||
|
const batch = await new Promise<any[]>((resolve) => dirReader.readEntries(resolve));
|
||||||
|
if (batch.length > 0) {
|
||||||
|
entries = entries.concat(batch);
|
||||||
|
await readAllEntries();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await readAllEntries();
|
||||||
|
|
||||||
|
for (const child of entries) {
|
||||||
|
await processEntry(child, `${path}${entry.name}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < e.dataTransfer.items.length; i++) {
|
||||||
|
const item = e.dataTransfer.items[i];
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const entry = item.webkitGetAsEntry();
|
||||||
|
if (entry) {
|
||||||
|
promises.push(processEntry(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
if (filesToUpload.length > 0) {
|
||||||
|
await Promise.all(filesToUpload.map(({file, path}) =>
|
||||||
|
uploadFile(file, path.replace(/\/$/, ''))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
await Promise.all(droppedFiles.map(file => uploadFile(file)));
|
await Promise.all(droppedFiles.map(file => uploadFile(file)));
|
||||||
|
}
|
||||||
|
|
||||||
loadFiles(undefined, true);
|
loadFiles(undefined, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [needs2FA, setNeeds2FA] = useState(false);
|
const [needs2FA, setNeeds2FA] = useState(false);
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -24,7 +25,7 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { ok, status, data } = await api.login(username, password, needs2FA ? totpCode : undefined);
|
const { ok, status, data } = await api.login(username, password, needs2FA ? totpCode : undefined, rememberMe);
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
onSuccess();
|
onSuccess();
|
||||||
|
|
@ -189,6 +190,25 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!needs2FA && (
|
||||||
|
<label className="flex items-center gap-2 mt-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberMe}
|
||||||
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-bg-tertiary)',
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
accentColor: 'var(--color-accent)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
Remember me for 3 weeks
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
|
|
||||||
|
|
@ -121,10 +121,10 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
async login(username: string, password: string, totpCode?: string) {
|
async login(username: string, password: string, totpCode?: string, rememberMe?: boolean) {
|
||||||
const res = await this.request('/api/auth/login', {
|
const res = await this.request('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { username, password, totp_code: totpCode },
|
body: { username, password, totp_code: totpCode, remember_me: rememberMe },
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok && data.access_token) {
|
if (res.ok && data.access_token) {
|
||||||
|
|
|
||||||
Reference in a new issue