Add browser extension gRPC bridge
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# KeePassGO Browser Extension
|
||||
|
||||
Shared extension assets for Firefox and Chromium-based browsers live here.
|
||||
|
||||
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.invalid`
|
||||
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
||||
- `background.js` talks to the native messaging host `org.keepassgo.browser`
|
||||
- `content.js` fills username and password fields on the current page
|
||||
- `options.html` stores the local gRPC address and API token in browser extension storage
|
||||
|
||||
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
|
||||
@@ -0,0 +1,195 @@
|
||||
const ext = globalThis.browser ?? globalThis.chrome;
|
||||
const nativeHost = "org.keepassgo.browser";
|
||||
const defaultSettings = {
|
||||
grpcAddress: "127.0.0.1:47777",
|
||||
bearerToken: ""
|
||||
};
|
||||
|
||||
function storageGet(keys) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.storage.local.get(keys, (value) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function storageSet(value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.storage.local.set(value, () => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tabsQuery(query) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.tabs.query(query, (tabs) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(tabs);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tabsSendMessage(tabId, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.tabs.sendMessage(tabId, message, (response) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function connectNative(message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
|
||||
const error = ext.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const stored = await storageGet(["grpcAddress", "bearerToken"]);
|
||||
return {
|
||||
grpcAddress: (stored.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||
bearerToken: (stored.bearerToken || "").trim()
|
||||
};
|
||||
}
|
||||
|
||||
async function activePageContext() {
|
||||
const [tab] = await tabsQuery({ active: true, currentWindow: true });
|
||||
return {
|
||||
tabId: tab?.id ?? null,
|
||||
url: typeof tab?.url === "string" ? tab.url : ""
|
||||
};
|
||||
}
|
||||
|
||||
async function statusForPage() {
|
||||
const settings = await loadSettings();
|
||||
const page = await activePageContext();
|
||||
if (!settings.bearerToken) {
|
||||
return {
|
||||
success: false,
|
||||
configured: false,
|
||||
status: null,
|
||||
pageUrl: page.url,
|
||||
matches: [],
|
||||
error: "Set an API token in extension settings."
|
||||
};
|
||||
}
|
||||
|
||||
const status = await connectNative({
|
||||
action: "status",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken
|
||||
});
|
||||
if (!status.success || status.status?.locked || !page.url.startsWith("http")) {
|
||||
return {
|
||||
success: status.success,
|
||||
configured: true,
|
||||
status: status.status ?? null,
|
||||
pageUrl: page.url,
|
||||
matches: [],
|
||||
error: status.error ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
const matches = await connectNative({
|
||||
action: "find-logins",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken,
|
||||
url: page.url
|
||||
});
|
||||
return {
|
||||
success: matches.success,
|
||||
configured: true,
|
||||
status: matches.status ?? status.status ?? null,
|
||||
pageUrl: page.url,
|
||||
matches: matches.matches ?? [],
|
||||
error: matches.error ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
async function fillLogin(entryId) {
|
||||
const settings = await loadSettings();
|
||||
const page = await activePageContext();
|
||||
if (!settings.bearerToken) {
|
||||
throw new Error("API token is not configured.");
|
||||
}
|
||||
if (page.tabId == null) {
|
||||
throw new Error("No active tab is available.");
|
||||
}
|
||||
|
||||
const response = await connectNative({
|
||||
action: "get-login",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken,
|
||||
entryId,
|
||||
url: page.url
|
||||
});
|
||||
if (!response.success || !response.credential) {
|
||||
throw new Error(response.error || "KeePassGO did not return a credential.");
|
||||
}
|
||||
|
||||
const fillResponse = await tabsSendMessage(page.tabId, {
|
||||
type: "keepassgo-fill-credential",
|
||||
credential: response.credential
|
||||
});
|
||||
if (!fillResponse?.ok) {
|
||||
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
||||
}
|
||||
return {
|
||||
credential: response.credential,
|
||||
pageUrl: page.url
|
||||
};
|
||||
}
|
||||
|
||||
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
(async () => {
|
||||
switch (message?.type) {
|
||||
case "keepassgo-popup-state":
|
||||
sendResponse(await statusForPage());
|
||||
return;
|
||||
case "keepassgo-fill-entry":
|
||||
sendResponse({ success: true, ...(await fillLogin(message.entryId)) });
|
||||
return;
|
||||
case "keepassgo-load-settings":
|
||||
sendResponse({ success: true, settings: await loadSettings() });
|
||||
return;
|
||||
case "keepassgo-save-settings":
|
||||
await storageSet({
|
||||
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||
bearerToken: String(message.settings?.bearerToken || "").trim()
|
||||
});
|
||||
sendResponse({ success: true });
|
||||
return;
|
||||
default:
|
||||
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
||||
}
|
||||
})().catch((error) => {
|
||||
sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
return true;
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
function isVisibleInput(input) {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return false;
|
||||
}
|
||||
if (input.disabled || input.readOnly) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(input);
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false;
|
||||
}
|
||||
return input.offsetParent !== null || style.position === "fixed";
|
||||
}
|
||||
|
||||
function dispatchFillEvents(input) {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function findPasswordInput() {
|
||||
return Array.from(document.querySelectorAll('input[type="password"]')).find(isVisibleInput) || null;
|
||||
}
|
||||
|
||||
function findUsernameInput(passwordInput) {
|
||||
const form = passwordInput?.form || null;
|
||||
const scope = form || document;
|
||||
const candidates = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])'))
|
||||
.filter(isVisibleInput);
|
||||
if (passwordInput) {
|
||||
const sameForm = candidates.find((input) => input.form === passwordInput.form);
|
||||
if (sameForm) {
|
||||
return sameForm;
|
||||
}
|
||||
}
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
function fillCredential(credential) {
|
||||
const passwordInput = findPasswordInput();
|
||||
const usernameInput = findUsernameInput(passwordInput);
|
||||
|
||||
if (usernameInput && credential.username) {
|
||||
usernameInput.focus();
|
||||
usernameInput.value = credential.username;
|
||||
dispatchFillEvents(usernameInput);
|
||||
}
|
||||
if (passwordInput && credential.password) {
|
||||
passwordInput.focus();
|
||||
passwordInput.value = credential.password;
|
||||
dispatchFillEvents(passwordInput);
|
||||
}
|
||||
|
||||
if (!usernameInput && !passwordInput) {
|
||||
return { ok: false, error: "No fillable username or password fields were found." };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
(globalThis.browser ?? globalThis.chrome).runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message?.type !== "keepassgo-fill-credential") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
sendResponse(fillCredential(message.credential || {}));
|
||||
} catch (error) {
|
||||
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "KeePassGO Browser",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "KeePassGO Browser",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "browser@keepassgo.invalid"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KeePassGO Browser Settings</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="surface settings">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Browser Settings</h1>
|
||||
<p class="subtle">Configure how the extension reaches KeePassGO.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form id="settings-form" class="settings-form">
|
||||
<label>
|
||||
<span>gRPC address</span>
|
||||
<input id="grpc-address" name="grpc-address" type="text" value="127.0.0.1:47777" autocomplete="off">
|
||||
</label>
|
||||
<label>
|
||||
<span>API token</span>
|
||||
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
<p id="settings-status" class="subtle"></p>
|
||||
</form>
|
||||
</main>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
const extOptions = globalThis.browser ?? globalThis.chrome;
|
||||
|
||||
function runtimeSend(message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
extOptions.runtime.sendMessage(message, (response) => {
|
||||
const error = extOptions.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const response = await runtimeSend({ type: "keepassgo-load-settings" });
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || "Could not load settings.");
|
||||
}
|
||||
document.getElementById("grpc-address").value = response.settings.grpcAddress || "127.0.0.1:47777";
|
||||
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
|
||||
}
|
||||
|
||||
async function saveSettings(event) {
|
||||
event.preventDefault();
|
||||
const status = document.getElementById("settings-status");
|
||||
status.textContent = "Saving…";
|
||||
try {
|
||||
const response = await runtimeSend({
|
||||
type: "keepassgo-save-settings",
|
||||
settings: {
|
||||
grpcAddress: document.getElementById("grpc-address").value,
|
||||
bearerToken: document.getElementById("bearer-token").value
|
||||
}
|
||||
});
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || "Could not save settings.");
|
||||
}
|
||||
status.textContent = "Saved.";
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("settings-form").addEventListener("submit", saveSettings);
|
||||
void loadSettings();
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KeePassGO Browser</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="popup">
|
||||
<main class="surface">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>KeePassGO</h1>
|
||||
<p id="page-host" class="subtle">Checking current page</p>
|
||||
</div>
|
||||
<a href="options.html" target="_blank" rel="noreferrer" class="link-button">Settings</a>
|
||||
</header>
|
||||
<section id="status-card" class="status-card">
|
||||
<strong id="status-title">Loading</strong>
|
||||
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Matches</h2>
|
||||
<div id="matches" class="match-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
const extPopup = globalThis.browser ?? globalThis.chrome;
|
||||
|
||||
function runtimeSend(message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
extPopup.runtime.sendMessage(message, (response) => {
|
||||
const error = extPopup.runtime.lastError;
|
||||
if (error) {
|
||||
reject(new Error(error.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hostFromURL(rawURL) {
|
||||
try {
|
||||
return new URL(rawURL).host || rawURL;
|
||||
} catch (_error) {
|
||||
return rawURL || "Current page";
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(title, message, tone) {
|
||||
const card = document.getElementById("status-card");
|
||||
card.dataset.tone = tone || "neutral";
|
||||
document.getElementById("status-title").textContent = title;
|
||||
document.getElementById("status-message").textContent = message;
|
||||
}
|
||||
|
||||
function renderMatches(state) {
|
||||
const root = document.getElementById("matches");
|
||||
root.textContent = "";
|
||||
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "subtle";
|
||||
empty.textContent = "No matching entries for this page.";
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of state.matches) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "match-row";
|
||||
row.innerHTML = `
|
||||
<span class="match-main">
|
||||
<strong>${match.title}</strong>
|
||||
<span class="subtle">${match.username || "No username"}</span>
|
||||
</span>
|
||||
<span class="quality">${match.quality || ""}</span>
|
||||
`;
|
||||
row.addEventListener("click", async () => {
|
||||
row.disabled = true;
|
||||
try {
|
||||
const result = await runtimeSend({ type: "keepassgo-fill-entry", entryId: match.id });
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || "Fill failed.");
|
||||
}
|
||||
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||
} catch (error) {
|
||||
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
|
||||
} finally {
|
||||
row.disabled = false;
|
||||
}
|
||||
});
|
||||
root.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const state = await runtimeSend({ type: "keepassgo-popup-state" });
|
||||
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||
|
||||
if (!state.configured) {
|
||||
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||
renderMatches({ matches: [] });
|
||||
return;
|
||||
}
|
||||
if (!state.success) {
|
||||
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
||||
renderMatches({ matches: [] });
|
||||
return;
|
||||
}
|
||||
if (state.status?.locked) {
|
||||
setStatus("Vault locked", "Unlock KeePassGO, then open the popup again.", "warning");
|
||||
renderMatches({ matches: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||
setStatus("Ready", count === 0 ? "KeePassGO is connected." : `${count} matching entr${count === 1 ? "y" : "ies"} found.`, "ready");
|
||||
renderMatches(state);
|
||||
} catch (error) {
|
||||
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
||||
renderMatches({ matches: [] });
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,174 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #214f44;
|
||||
--ink-soft: #4d6d66;
|
||||
--surface: #fffdfa;
|
||||
--surface-2: #f2f7f3;
|
||||
--line: #d7e3dc;
|
||||
--accent: #255f4a;
|
||||
--accent-soft: #dfeee6;
|
||||
--warn: #9f5f0e;
|
||||
--error: #9f2f2f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.4 "Noto Sans", "Liberation Sans", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top right, #ecf5ef, transparent 38%),
|
||||
linear-gradient(180deg, #f8fbf8, #eef4f0);
|
||||
}
|
||||
|
||||
body.popup {
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.surface {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-card[data-tone="ready"] {
|
||||
border-color: #c5dccf;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.status-card[data-tone="warning"] {
|
||||
border-color: #e4d0ae;
|
||||
background: #fbf4e7;
|
||||
}
|
||||
|
||||
.status-card[data-tone="error"] {
|
||||
border-color: #e4bcbc;
|
||||
background: #fcf1f1;
|
||||
}
|
||||
|
||||
.match-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.match-row,
|
||||
button,
|
||||
.link-button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.match-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.match-row:hover,
|
||||
button:hover,
|
||||
.link-button:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.match-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quality {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.link-button {
|
||||
padding: 10px 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
Reference in New Issue
Block a user