feat: move to secure OS credential storage so that you never get logged out again

This commit is contained in:
Simeon Radivoev 2026-02-10 00:35:37 +02:00
parent d6e0a8350a
commit ef08fa6114
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
15 changed files with 493 additions and 276 deletions

View file

@ -0,0 +1,49 @@
import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
export function OptionInput (data: {
name: string;
type: HTMLInputTypeAttribute;
className?: string;
placeholder?: string;
icon?: JSX.Element;
value?: string;
defaultValue?: string;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: ChangeEventHandler<HTMLInputElement>;
})
{
const inputRef = useRef<HTMLInputElement>(null);
const option = useOptionContext({
onOptionEnterPress ()
{
inputRef.current?.focus();
},
});
return (
<label className="flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent">
<span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>
<input
ref={inputRef}
id={data.name}
name={data.name}
value={data.value}
defaultValue={data.defaultValue}
type={data.type}
onFocus={() => option.focus()}
placeholder={data.placeholder}
onChange={data.onChange}
onBlur={data.onBlur}
className={twMerge(
"input grow rounded-full ring-primary-content focus:ring-3",
data.className,
)}
/>
</label>
);
}

View file

@ -0,0 +1,89 @@
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { createContext, JSX, useContext, useEffect, useMemo } from "react";
import { twMerge } from "tailwind-merge";
export const OptionContext = createContext(
{} as {
focused: boolean;
focus: (focusDetails?: FocusDetails | undefined) => void;
eventTarget: EventTarget;
},
);
export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
{
const context = useContext(OptionContext);
useEffect(() =>
{
if (params?.onOptionEnterPress)
{
context.eventTarget.addEventListener(
"onEnterPress",
params.onOptionEnterPress,
);
}
return () =>
{
if (params?.onOptionEnterPress)
{
context.eventTarget.removeEventListener(
"onEnterPress",
params.onOptionEnterPress,
);
}
};
}, [context.eventTarget]);
return context;
}
export function OptionSpace (data: {
id?: string;
className?: string;
focusable?: boolean;
children: JSX.Element;
label?: string | JSX.Element;
})
{
const eventTarget = useMemo(() => new EventTarget(), []);
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
focusKey: data.id,
focusable: data.focusable !== false,
trackChildren: true,
onEnterPress ()
{
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
},
});
return (<FocusContext value={focusKey}>
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
<li
ref={ref}
className={twMerge("flex sm:p-2 md:p-4 pl-8! rounded-full bg-base-content/1", classNames(
{
"text-primary-content bg-primary ": focused || hasFocusedChild,
}),
data.className,
)}
>
<div className="label flex-1 md:text-lg pr-4">
{typeof data.label === "string" ? (
<label
className={classNames({
"text-primary-content font-semibold": focused,
})}
>
{data.label}
</label>
) : (
data.label
)}
</div>
{data.children}
</li>
</OptionContext>
</FocusContext>
);
}

View file

@ -0,0 +1,38 @@
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { HTMLInputTypeAttribute, JSX } from "react";
import { OptionInput } from "./OptionInput";
import { OptionSpace } from "./OptionSpace";
import classNames from "classnames";
import { TriangleAlert } from "lucide-react";
// export useFieldContext for use in your custom components
export const { fieldContext, formContext, useFieldContext } =
createFormHookContexts();
export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsFormContext } = createFormHook({
fieldContext,
formContext,
fieldComponents: { FormOption },
formComponents: {}
});
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; })
{
const field = useFieldContext<string>();
return <OptionSpace label={<div className="flex gap-2">
{data.label}
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
{field.state.meta.errors.map(e => e.message).join(',')}
</div>}
</div>}>
<OptionInput
icon={data.icon}
name={field.name}
value={field.state.value}
type={data.type}
onChange={e => field.handleChange(e.target.value)}
placeholder={data.placeholder}
className={classNames({ "ring-4 ring-accent": field.getMeta().isDirty })}
/>
</OptionSpace>;;
}