Files
seasoned/src/components/settings/PasswordGenerator.vue
Kevin c309016299 Feat/settings page redesign (#104)
* include credentials on login fetch requests, allows set header response

* Add theme composable and utility improvements

- Create useTheme composable for centralized theme management
- Update main.ts to use useTheme for initialization
- Generalize getCookie utility in user module
- Add utility functions for data formatting

* Add Plex integration composables and icons

- Create usePlexAuth composable for Plex OAuth flow
- Create usePlexApi composable for Plex API interactions
- Create useRandomWords composable for password generation
- Add Plex-related icons (IconPlex, IconServer, IconSync)
- Add Plex helper utilities
- Update API with Plex-related endpoints

* Add storage management components for data & privacy section

- Create StorageManager component for browser storage overview
- Create StorageSectionBrowser for localStorage/sessionStorage/cookies
- Create StorageSectionServer for server-side data (mock)
- Create ExportSection for data export functionality
- Refactor DataExport component with modular sections
- Add storage icons (IconCookie, IconDatabase, IconTimer)
- Implement collapsible sections with visual indicators
- Add colored borders per storage type
- Display item counts and total size in headers

* Add theme, password, and security settings components

- Create ThemePreferences with visual theme selector
- Create PasswordGenerator with passphrase and random modes
- Create SecuritySettings wrapper for password management
- Update ChangePassword to work with new layout
- Implement improved slider UX with visual feedback
- Add theme preview cards with gradients
- Standardize component styling and typography

* Add Plex settings and authentication components

- Create PlexSettings component for Plex account management
- Create PlexAuthButton with improved OAuth flow
- Create PlexServerInfo for server details display
- Use icon components instead of inline SVGs
- Add sync and unlink functionality
- Implement user-friendly authentication flow

* Redesign settings page with two-column layout and ProfileHero

- Create ProfileHero component with avatar and user info
- Create RequestHistory component for Plex requests (placeholder)
- Redesign SettingsPage with modern two-column grid layout
- Add shared-settings.scss for consistent styling
- Organize sections: Appearance, Security, Integrations, Data & Privacy
- Implement responsive mobile layout
- Standardize typography (h2: 1.5rem, 700 weight)
- Add compact modifier for tighter sections
2026-03-08 21:16:36 +01:00

598 lines
13 KiB
Vue

<template>
<div class="password-generator">
<div class="generator-panel">
<div class="generator-tabs">
<button
:class="['tab', { 'tab--active': mode === 'words' }]"
@click="mode = 'words'"
>
Passphrase
</button>
<button
:class="['tab', { 'tab--active': mode === 'chars' }]"
@click="mode = 'chars'"
>
Random
</button>
</div>
<div v-if="mode === 'words'" class="generator-content">
<div class="generator-header">
<h4>Passphrase Generator</h4>
<p>Create a memorable password using random words</p>
</div>
<div class="password-display" @click="copyPassword">
<span class="password-text password-text--mono">{{
generatedPassword
}}</span>
<button
class="copy-btn"
:title="copied ? 'Copied!' : 'Click to copy'"
>
{{ copied ? "✓" : "📋" }}
</button>
</div>
<div class="generator-options">
<div class="option-row">
<div class="slider-header">
<label>Words</label>
<span class="slider-value">{{ wordCount }}</span>
</div>
<input
v-model.number="wordCount"
type="range"
min="3"
max="7"
class="slider"
@input="generateWordsPassword"
/>
<div class="slider-labels">
<span>3</span>
<span>7</span>
</div>
</div>
</div>
</div>
<div v-else class="generator-content">
<div class="generator-header">
<h4>Random Password Generator</h4>
<p>Generate a secure random password</p>
</div>
<div class="password-display" @click="copyPassword">
<span class="password-text password-text--mono">{{
generatedPassword
}}</span>
<button
class="copy-btn"
:title="copied ? 'Copied!' : 'Click to copy'"
>
{{ copied ? "✓" : "📋" }}
</button>
</div>
<div class="generator-options">
<div class="option-row">
<div class="slider-header">
<label>Length</label>
<span class="slider-value">{{ charLength }}</span>
</div>
<input
v-model.number="charLength"
type="range"
min="12"
max="46"
class="slider"
@input="generateCharsPassword"
/>
<div class="slider-labels">
<span>12</span>
<span>46</span>
</div>
</div>
<div class="option-row checkbox-row">
<label>
<input
v-model="includeUppercase"
type="checkbox"
@change="generateCharsPassword"
/>
Uppercase (A-Z)
</label>
<label>
<input
v-model="includeLowercase"
type="checkbox"
@change="generateCharsPassword"
/>
Lowercase (a-z)
</label>
<label>
<input
v-model="includeNumbers"
type="checkbox"
@change="generateCharsPassword"
/>
Numbers (0-9)
</label>
<label>
<input
v-model="includeSymbols"
type="checkbox"
@change="generateCharsPassword"
/>
Symbols (!@#$)
</label>
</div>
</div>
</div>
<div class="generator-actions">
<button class="action-btn action-btn--secondary" @click="regenerate">
<IconActivity class="btn-icon" />
Regenerate
</button>
<button class="action-btn action-btn--primary" @click="usePassword">
Use This Password
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
import { useRandomWords } from "@/composables/useRandomWords";
interface Emit {
(e: "passwordGenerated", password: string): void;
}
const emit = defineEmits<Emit>();
const mode = ref<"words" | "chars">("words");
const generatedPassword = ref("");
const copied = ref(false);
// Words mode options
const wordCount = ref(4);
const separator = ref("-");
// Chars mode options
const charLength = ref(16);
const includeUppercase = ref(true);
const includeLowercase = ref(true);
const includeNumbers = ref(true);
const includeSymbols = ref(true);
const { getRandomWords } = useRandomWords();
async function generateWordsPassword() {
const words = await getRandomWords(wordCount.value);
const password = words.join(separator.value);
generatedPassword.value = password;
}
function generateCharsPassword() {
let charset = "";
if (includeUppercase.value) charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if (includeLowercase.value) charset += "abcdefghijklmnopqrstuvwxyz";
if (includeNumbers.value) charset += "0123456789";
if (includeSymbols.value) charset += "!@#$%^&*()_+-=[]{}|;:,.<>?";
if (charset === "") charset = "abcdefghijklmnopqrstuvwxyz";
let password = "";
for (let i = 0; i < charLength.value; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
generatedPassword.value = password;
}
async function regenerate() {
if (mode.value === "words") {
await generateWordsPassword();
} else {
generateCharsPassword();
}
}
async function copyPassword() {
try {
await navigator.clipboard.writeText(generatedPassword.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
function usePassword() {
emit("passwordGenerated", generatedPassword.value);
// TODO: emit
// showGenerator.value = false;
}
watch(mode, async () => {
if (mode.value === "words") {
await generateWordsPassword();
} else {
generateCharsPassword();
}
});
onMounted(generateWordsPassword);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.password-generator {
margin-bottom: 1rem;
}
.generator-panel {
margin-top: 0.75rem;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
border: 1px solid var(--background-40);
@include mobile-only {
padding: 0.75rem;
}
}
.generator-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab {
flex: 1;
padding: 0.65rem 1rem;
background-color: transparent;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
color: $text-color-70;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
&:hover {
background-color: var(--background-40);
}
&--active {
background-color: var(--highlight-color);
border-color: var(--highlight-color);
color: $white;
}
}
.generator-content {
margin-bottom: 1rem;
}
.generator-header {
margin-bottom: 0.75rem;
h4 {
margin: 0 0 0.15rem 0;
font-size: 0.95rem;
font-weight: 500;
color: $text-color;
line-height: 1.3;
}
p {
margin: 0;
font-size: 0.8rem;
color: $text-color-70;
line-height: 1.3;
}
}
.password-display {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background-color: var(--background-color);
border: 2px solid var(--highlight-color);
border-radius: 0.5rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: var(--background-40);
}
@include mobile-only {
padding: 0.6rem;
}
}
.password-text {
flex: 1;
font-size: 1.8rem;
font-weight: 500;
color: var(--highlight-color);
user-select: all;
word-break: break-all;
word-break: break-word;
-webkit-hyphens: auto;
hyphens: auto;
@include mobile-only {
font-size: 0.95rem;
}
&--mono {
font-family: "Courier New", monospace;
@include mobile-only {
font-size: 0.85rem;
}
}
}
.copy-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
}
.generator-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.option-row {
display: flex;
flex-direction: column;
gap: 0.75rem;
label {
font-size: 0.95rem;
color: $text-color;
font-weight: 600;
line-height: 1.2;
}
&.checkbox-row {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
@include mobile-only {
flex-direction: column;
gap: 0.6rem;
}
label {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 400;
cursor: pointer;
font-size: 0.85rem;
input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
}
}
}
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.slider-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: right;
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: $text-color-50;
margin-top: 0.25rem;
padding: 0 0.25rem;
}
.slider {
width: 100%;
height: 10px;
border-radius: 5px;
background: var(--background-40);
outline: none;
appearance: none;
cursor: pointer;
transition: background 0.2s;
margin: 0.5rem 0;
@include mobile-only {
height: 12px;
}
&:hover {
background: var(--background-40);
}
&::-webkit-slider-thumb {
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--highlight-color);
cursor: grab;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
margin-top: -7px;
@include mobile-only {
width: 28px;
height: 28px;
margin-top: -8px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
}
}
&::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--highlight-color);
cursor: grab;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
@include mobile-only {
width: 28px;
height: 28px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
}
}
&::-webkit-slider-runnable-track {
height: 10px;
border-radius: 5px;
@include mobile-only {
height: 12px;
}
}
&::-moz-range-track {
height: 10px;
border-radius: 5px;
background: var(--background-40);
@include mobile-only {
height: 12px;
}
}
}
.separator-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
font-family: "Courier New", monospace;
text-align: center;
&:focus {
outline: none;
border-color: var(--highlight-color);
}
&::placeholder {
color: $text-color-50;
font-family: inherit;
}
}
.generator-actions {
display: flex;
gap: 0.5rem;
@include mobile-only {
flex-direction: column;
gap: 0.5rem;
}
}
.action-btn {
flex: 1;
padding: 0.6rem 1rem;
border: none;
border-radius: 0.25rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
&--secondary {
background-color: var(--background-color);
color: $text-color;
border: 1px solid var(--background-40);
&:hover {
background-color: var(--background-40);
}
}
&--primary {
background-color: var(--highlight-color);
color: $white;
&:hover {
background-color: var(--color-green-90);
}
}
}
.btn-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>