add: developer panel

This commit is contained in:
trisua 2025-06-14 20:26:54 -04:00
parent ebded00fd3
commit 39574df691
44 changed files with 982 additions and 84 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto"
version = "6.0.0"
version = "7.0.0"
edition = "2024"
[features]

View file

@ -122,6 +122,8 @@ pub const FORGE_INFO: &str = include_str!("./public/html/forge/info.lisp");
pub const FORGE_TICKETS: &str = include_str!("./public/html/forge/tickets.lisp");
pub const DEVELOPER_HOME: &str = include_str!("./public/html/developer/home.lisp");
pub const DEVELOPER_APP: &str = include_str!("./public/html/developer/app.lisp");
pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp");
// langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -408,6 +410,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"forge/tickets.html"(crate::assets::FORGE_TICKETS) --config=config --lisp plugins);
write_template!(html_path->"developer/home.html"(crate::assets::DEVELOPER_HOME) -d "developer" --config=config --lisp plugins);
write_template!(html_path->"developer/app.html"(crate::assets::DEVELOPER_APP) --config=config --lisp plugins);
write_template!(html_path->"developer/link.html"(crate::assets::DEVELOPER_LINK) --config=config --lisp plugins);
html_path
}

View file

@ -212,7 +212,17 @@ version = "1.0.0"
"forge:action.reopen" = "Reopen"
"forge:action.close" = "Close"
"developer:label.for_developers" = "for Developers"
"developer:label.my_apps" = "My apps"
"developer:label.create_new" = "Create new app"
"developer:label.homepage" = "Homepage"
"developer:label.redirect" = "Redirect URL"
"developer:label.change_title" = "Change title"
"developer:label.change_homepage" = "Change homepage"
"developer:label.change_redirect" = "Change redirect URL"
"developer:label.change_quota_status" = "Change quota status"
"developer:label.manage_scopes" = "Manage scopes"
"developer:label.scopes" = "Scopes"
"developer:label.guides_and_help" = "Guides & help"
"developer:action.delete" = "Delete app"
"developer:action.authorize" = "Authorize"

View file

@ -100,19 +100,14 @@ macro_rules! get_user_from_token {
}};
($jar:ident, $db:expr, $grant_scope:expr) => {{
if let Some(token) = $jar.get("Atto-Grant")
&& let Some(verifier) = $jar.get("Atto-Grant-Verifier")
{
if let Some(token) = $jar.get("Atto-Grant") {
// grant token
let verifier = verifier.to_string().replace("Atto-Grant-Verifier=", "");
match $db
.get_user_by_grant_token(&token.to_string().replace("Atto-Grant=", ""))
.get_user_by_grant_token(&token.to_string().replace("Atto-Grant=", ""), true)
.await
{
Ok((grant, ua)) => {
if grant.scopes.contains(&$grant_scope)
&& grant.check_verifier(&verifier).is_ok()
{
if grant.scopes.contains(&$grant_scope) {
if ua.permissions.check_banned() {
Some(tetratto_core::model::auth::User::banned())
} else {

View file

@ -191,6 +191,7 @@ table ol {
.card.lowered {
background: var(--color-lowered);
color: var(--color-text-lowered);
}
.card-nest {

View file

@ -0,0 +1,348 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ app.title }} - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "w-full flex flex-col gap-2")
(div
("id" "manage_fields")
("class" "card lowered flex flex-col gap-2")
(text "{% if is_helper -%}")
(div
("class" "card-nest")
(div
("class" "card small")
(b (str (text "developer:label.change_quota_status"))))
(div
("class" "card")
(select
("onchange" "save_quota_status(event)")
(option
("value" "Limited")
("selected" "{% if app.quota_status == 'Limited' -%}true{% else %}false{%- endif %}")
(text "Limited"))
(option
("value" "Unlimited")
("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}")
(text "Unlimited")))))
(text "{%- endif %}")
(div
("class" "card-nest")
(div
("class" "card small")
(b (str (text "developer:label.change_title"))))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "change_title(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "title")
(text "{{ text \"communities:label.new_title\" }}"))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "new title")
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))))
(div
("class" "card-nest")
(div
("class" "card small")
(b (str (text "developer:label.change_homepage"))))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "change_homepage(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "homepage")
(text "{{ text \"developer:label.homepage\" }}"))
(input
("type" "url")
("name" "homepage")
("id" "homepage")
("placeholder" "new homepage")
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))))
(div
("class" "card-nest")
(div
("class" "card small")
(b (str (text "developer:label.change_redirect"))))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "change_redirect(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "redirect")
(text "{{ text \"developer:label.redirect\" }}"))
(input
("type" "url")
("name" "redirect")
("id" "redirect")
("placeholder" "new redirect URL")
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))))
(div
("class" "card-nest")
(div
("class" "card small")
(b (str (text "developer:label.manage_scopes"))))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "change_scopes(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "scopes")
(text "{{ text \"developer:label.scopes\" }}"))
(input
("type" "text")
("name" "scopes")
("id" "scopes")
("placeholder" "new scopes")
("required" "")
("minlength" "2")
("value" "{% for scope in app.scopes -%} {{ scope }} {% endfor %}")))
(pre ("class" "hidden red w-full") (code ("id" "scope_error_message") ("style" "white-space: pre-wrap")))
(details
(summary ("class" "button lowered small") (icon (text "circle-help")) (text "Help"))
(div
("class" "card flex flex-col gap-1")
(span ("class" "fade") (text "Scopes should be separated by a single space."))
(a
("class" "button")
("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variants")
("target" "_blank")
(icon (text "external-link")) (text "Docs"))))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}"))))))
(div
("class" "card flex flex-col gap-2")
(ul
(li (b (text "Homepage: ")) (text "{{ app.homepage }}"))
(li (b (text "Redirect URL: ")) (text "{{ app.redirect }}"))
(li (b (text "Quota status: ")) (text "{{ app.quota_status }}"))
(li (b (text "User grants: ")) (text "{{ app.grants }}"))
(li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}")))
(a
("class" "button")
("href" "https://tetratto.com/reference/tetratto/model/apps/struct.ThirdPartyApp.html#structfield.redirect")
("target" "_blank")
(icon (text "external-link")) (text "Docs")))
(div
("class" "card-nest")
(div
("class" "card small flex gap-1 items-center red")
(text "{{ icon \"skull\" }}")
(b
(text "{{ text \"communities:label.danger_zone\" }}")))
(div
("class" "card flex flex-wrap gap-2")
(button
("class" "red lowered")
("onclick" "delete_app()")
(text "{{ icon \"trash\" }}")
(span (str (text "developer:action.delete"))))))
(div
("class" "flex gap-2 flex-wrap")
(a
("href" "/developer")
("class" "button secondary")
(text "{{ icon \"arrow-left\" }}")
(span
(text "{{ text \"general:action.back\" }}"))))))
(script
(text "setTimeout(() => {
globalThis.save_quota_status = (event) => {
const selected = event.target.selectedOptions[0];
fetch(\"/api/v1/apps/{{ app.id }}/quota_status\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
quota_status: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.change_title = async (e) => {
e.preventDefault();
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/apps/{{ app.id }}/title\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.change_homepage = async (e) => {
e.preventDefault();
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/apps/{{ app.id }}/homepage\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
homepage: e.target.homepage.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.change_redirect = async (e) => {
e.preventDefault();
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/apps/{{ app.id }}/redirect\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
redirect: e.target.redirect.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.change_scopes = async (e) => {
e.preventDefault();
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This will only impact new grants.\",
]))
) {
return;
}
fetch(\"/api/v1/apps/{{ app.id }}/scopes\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
scopes: e.target.scopes.value.trim().split(\" \"),
}),
})
.then((res) => res.text())
.then((res) => {
if (res.startsWith(\"{\")) {
const r = JSON.parse(res);
trigger(\"atto::toast\", [r.ok ? \"success\" : \"error\", r.message]);
document.getElementById(\"scope_error_message\").parentElement.classList.add(\"hidden\");
} else {
document.getElementById(\"scope_error_message\").innerText = res;
document.getElementById(\"scope_error_message\").parentElement.classList.remove(\"hidden\");
document.getElementById(\"scope_error_message\").parentElement.parentElement.querySelector(\"details\").setAttribute(\"open\", \"\");
}
});
};
globalThis.delete_app = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This action is permanent.\",
]))
) {
return;
}
fetch(`/api/v1/apps/{{ app.id }}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
}, 250);"))
(text "{% endblock %}")

View file

@ -85,7 +85,33 @@
("class" "date")
(text "{{ item.created }}"))
(text "; {{ item.quota_status }} mode; {{ item.grants }} users")))
(text "{% endfor %}"))))
(text "{% endfor %}")))
; useful links
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(icon (text "circle-help"))
(str (text "developer:label.guides_and_help")))
(div
("class" "card")
(ul
(li
(a ("href" "https://trisua.com/t/tetratto") (text "Source code")))
(li
(a ("href" "https://tetratto.com/reference/tetratto/index.html") (text "Source code reference")))
(li
(a ("href" "https://tetratto.com/reference/tetratto/model/struct.ApiReturn.html") (text "API response structure")))
(li
(a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html") (text "App scopes")))
(li
(a ("href" "https://tetratto.com/reference/tetratto/model/permissions/struct.FinePermission.html") (text "User permissions")))
(li
(a ("href" "https://tetratto.com/reference/tetratto/model/communities_permissions/struct.CommunityPermission.html") (text "Community member permissions")))
(li
(a ("href" "https://tetratto.com/forge/tetratto") (text "Report issues")))))))
(script
(text "async function create_app_from_form(e) {

View file

@ -0,0 +1,86 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ app.title }} - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "w-full flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card flex flex-col gap-2")
(h4 (text "Would you like to allow \"{{ app.title }}\" to access your account?"))
(p (text "This app is requesting the following permissions on your account:"))
(div
("class" "card")
(ul
(text "{% for scope in app.scopes -%}")
(li
(a
("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variant.{{ scope }}")
("target" "_blank")
(text "{{ scope }}")))
(text "{%- endfor %}")))
(p (text "You can revoke this app's permissions at any time through your connection settings.")))
(div
("class" "card flex gap-2")
(button
("onclick" "authorize()")
(str (text "developer:action.authorize")))
(a
("class" "button secondary")
("href" "javascript:window.close()")
(str (text "dialog:action.cancel")))))))
(script
(text "setTimeout(() => {
globalThis.authorize = async (event) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
const verifier = await trigger(\"connections::pkce_verifier\", [
128,
]);
const challenge = await trigger(\"connections::pkce_challenge\", [
verifier,
]);
fetch(\"/api/v1/apps/{{ app.id }}/grant\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
challenge
})
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
const search = new URLSearchParams(window.location.search);
search.append(\"verifier\", verifier);
search.append(\"token\", res.payload);
window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`;
}
});
};
}, 250);"))
(text "{% endblock %}")

View file

@ -255,6 +255,7 @@
MANAGE_EMOJIS: 1 << 25,
MANAGE_STACKS: 1 << 26,
STAFF_BADGE: 1 << 27,
MANAGE_APPS: 1 << 28,
},
],
);

View file

@ -845,7 +845,57 @@
("class" "w-content"))
(span
(text "Shown on profile")))))
(text "{% endfor %}"))
(text "{% endfor %}")
(text "{% for grant in profile_grants %}")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-4")
(div
("class" "flex items-center gap-2")
(icon (text "bot"))
(a
("class" "flush")
("href" "{{ grant[0].homepage }}")
("target" "_blank")
(b
("class" "flex items-center gap-2")
(text "{{ grant[0].title }}"))))
(span
("class" "fade flex items-center gap-2")
(icon (text "clock"))
(span ("class" "date") (text "{{ grant[1].last_updated }}"))))
(div
("class" "card flex flex-col gap-2")
(details
(summary (icon (text "scan-eye")) (text "{{ grant[1].scopes|length }} scope{{ grant[1].scopes|length|pluralize }}"))
(div
("class" "card lowered w-full")
(ul
(text "{% for scope in grant[1].scopes -%}")
(li
(a
("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html#variant.{{ scope }}")
("target" "_blank")
(text "{{ scope }}")))
(text "{%- endfor %}"))))
(button
("class" "lowered red small")
("onclick" "remove_grant('{{ grant[0].id }}')")
(text "{{ text \"general:action.delete\" }}"))))
(text "{% endfor %}")
(hr)
(a
("class" "button")
("href" "/developer")
(icon (text "code"))
(span
(text "{{ config.name }} ")
(str (text "developer:label.for_developers")))))
(script
("type" "application/json")
("id" "settings_json")
@ -897,6 +947,27 @@
});
};
globalThis.remove_grant = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/auth/user/{{ profile.id }}/grants/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.save_settings = () => {
fetch(\"/api/v1/auth/user/{{ profile.id }}/settings\", {
method: \"POST\",

View file

@ -818,6 +818,7 @@ media_theme_pref();
anchor.href.length === 0 ||
anchor.href.startsWith("https://github.com") ||
anchor.href.startsWith("https://trisua.com") ||
anchor.href.startsWith("https://tetratto.com") ||
anchor.href.startsWith("https://buy.stripe.com") ||
anchor.href.startsWith("https://billing.stripe.com") ||
anchor.href.startsWith("https://last.fm")

View file

@ -1,11 +1,20 @@
use crate::{
get_user_from_token,
routes::api::v1::{UpdateAppHomepage, UpdateAppQuotaStatus, UpdateAppRedirect, UpdateAppTitle},
routes::api::v1::{
CreateGrant, UpdateAppHomepage, UpdateAppQuotaStatus, UpdateAppRedirect, UpdateAppScopes,
UpdateAppTitle,
},
State,
};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{apps::ThirdPartyApp, permissions::FinePermission, ApiReturn, Error};
use tetratto_core::model::{
apps::ThirdPartyApp,
oauth::{AuthGrant, PkceChallengeMethod},
permissions::FinePermission,
ApiReturn, Error,
};
use tetratto_shared::{hash::random_id, unix_epoch_timestamp};
use super::CreateApp;
pub async fn create_request(
@ -129,6 +138,28 @@ pub async fn update_quota_status_request(
}
}
pub async fn update_scopes_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppScopes>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_app_scopes(id, &user, req.scopes).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "App updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,
@ -149,3 +180,50 @@ pub async fn delete_request(
Err(e) => Json(e.into()),
}
}
pub async fn grant_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<CreateGrant>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let app = match data.get_app_by_id(id).await {
Ok(a) => a,
Err(e) => return Json(e.into()),
};
if user.get_grant_by_app_id(id).is_some() {
return Json(Error::MiscError("This app already has a grant".to_string()).into());
}
let grant = AuthGrant {
app: app.id,
challenge: req.challenge,
method: PkceChallengeMethod::S256,
token: random_id(),
last_updated: unix_epoch_timestamp(),
scopes: app.scopes.clone(),
};
user.grants.push(grant.clone());
match data.update_user_grants(user.id, user.grants).await {
Ok(_) => {
if let Err(e) = data.incr_app_grants(id).await {
return Json(e.into());
}
Json(ApiReturn {
ok: true,
message: "User updated".to_string(),
payload: Some(grant.token),
})
}
Err(e) => Json(e.into()),
}
}

View file

@ -3,8 +3,8 @@ use crate::{
get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{
AppendAssociations, DeleteUser, DisableTotp, UpdateUserIsVerified, UpdateUserPassword,
UpdateUserRole, UpdateUserUsername,
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateUserIsVerified,
UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
},
State,
};
@ -31,7 +31,10 @@ use tetratto_core::{
#[cfg(feature = "redis")]
use tetratto_core::cache::redis::Commands;
use tetratto_shared::hash;
use tetratto_shared::{
hash::{self, random_id},
unix_epoch_timestamp,
};
pub async fn redirect_from_id(
Extension(data): Extension<State>,
@ -717,3 +720,104 @@ pub async fn get_user_gpa_request(
payload: Some(gpa),
});
}
/// Remove a grant token.
pub async fn remove_grant_request(
jar: CookieJar,
Path((user_id, app_id)): Path<(usize, usize)>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user_id != user.id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
if user.get_grant_by_app_id(app_id).is_none() {
return Json(Error::GeneralNotFound("grant".to_string()).into());
}
// remove grant
user.grants
.remove(user.grants.iter().position(|x| x.app == app_id).unwrap());
if let Err(e) = data.decr_app_grants(app_id).await {
return Json(e.into());
}
// update grants
match data.update_user_grants(user_id, user.grants).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "User updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Refresh a grant token.
pub async fn refresh_grant_request(
jar: CookieJar,
Path((user_id, app_id)): Path<(usize, usize)>,
Extension(data): Extension<State>,
Json(req): Json<RefreshGrantToken>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = if let Some(token) = jar.get("Atto-Grant") {
match data
.get_user_by_grant_token(&token.to_string().replace("Atto-Grant=", ""), false)
.await
{
Ok((grant, ua)) => {
if grant.check_verifier(&req.verifier).is_err() {
return Json(Error::NotAllowed.into());
}
if ua.permissions.check_banned() {
tetratto_core::model::auth::User::banned()
} else {
ua
}
}
Err(_) => return Json(Error::NotAllowed.into()),
}
} else {
return Json(Error::NotAllowed.into());
};
if user_id != user.id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let mut grant = match user.get_grant_by_app_id(app_id) {
Some(g) => g.to_owned(),
None => return Json(Error::GeneralNotFound("grant".to_string()).into()),
};
// remove grant
user.grants
.remove(user.grants.iter().position(|x| x.app == app_id).unwrap());
// refresh token
let token = random_id();
grant.token = token.clone();
grant.last_updated = unix_epoch_timestamp();
// add grant
user.grants.push(grant);
// update grants
match data.update_user_grants(user_id, user.grants).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "User updated".to_string(),
payload: Some(token),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -24,6 +24,7 @@ use tetratto_core::model::{
PollOption, PostContext,
},
communities_permissions::CommunityPermission,
oauth::AppScope,
permissions::FinePermission,
reactions::AssetType,
stacks::{StackMode, StackPrivacy, StackSort},
@ -348,6 +349,14 @@ pub fn routes() -> Router {
"/auth/user/{id}/followers",
get(auth::social::followers_request),
)
.route(
"/auth/user/{id}/grants/{app}",
delete(auth::profile::remove_grant_request),
)
.route(
"/auth/user/{id}/grants/{app}/refresh",
post(auth::profile::refresh_grant_request),
)
// apps
.route("/apps", post(apps::create_request))
.route("/apps/{id}/title", post(apps::update_title_request))
@ -357,7 +366,9 @@ pub fn routes() -> Router {
"/apps/{id}/quota_status",
post(apps::update_quota_status_request),
)
.route("/apps/{id}/scopes", post(apps::update_scopes_request))
.route("/apps/{id}", delete(apps::delete_request))
.route("/apps/{id}/grant", post(apps::grant_request))
// warnings
.route("/warnings/{id}", get(auth::user_warnings::get_request))
.route("/warnings/{id}", post(auth::user_warnings::create_request))
@ -816,3 +827,18 @@ pub struct UpdateAppRedirect {
pub struct UpdateAppQuotaStatus {
pub quota_status: AppQuota,
}
#[derive(Deserialize)]
pub struct UpdateAppScopes {
pub scopes: Vec<AppScope>,
}
#[derive(Deserialize)]
pub struct CreateGrant {
pub challenge: String,
}
#[derive(Deserialize)]
pub struct RefreshGrantToken {
pub verifier: String,
}

View file

@ -2,10 +2,11 @@ use super::render_error;
use crate::{assets::initial_context, get_lang, get_user_from_token, State};
use axum::{
response::{Html, IntoResponse},
extract::Path,
Extension,
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::Error;
use tetratto_core::model::{permissions::FinePermission, Error};
/// `/developer`
pub async fn home_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
@ -33,3 +34,62 @@ pub async fn home_request(jar: CookieJar, Extension(data): Extension<State>) ->
data.1.render("developer/home.html", &context).unwrap(),
))
}
/// `/developer/app/{id}`
pub async fn app_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let app = match data.0.get_app_by_id(id).await {
Ok(p) => p,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if user.id != app.owner && !user.permissions.check(FinePermission::MANAGE_APPS) {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("app", &app);
// return
Ok(Html(data.1.render("developer/app.html", &context).unwrap()))
}
/// `/auth/connections_link/app/{id}`
pub async fn connection_callback_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => return Html(render_error(Error::NotAllowed, &jar, &data, &None).await),
};
let app = match data.0.get_app_by_id(id).await {
Ok(p) => p,
Err(e) => return Html(render_error(e, &jar, &data, &Some(user)).await),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("app", &app);
Html(data.1.render("developer/link.html", &context).unwrap())
}

View file

@ -68,6 +68,10 @@ pub fn routes() -> Router {
"/auth/connections_link/{service}",
get(auth::connection_callback_request),
)
.route(
"/auth/connections_link/app/{id}",
get(developer::connection_callback_request),
)
// profile
.route("/settings", get(profile::settings_request))
.route("/@{username}", get(profile::posts_request))
@ -119,6 +123,7 @@ pub fn routes() -> Router {
.route("/forge/{title}/members", get(communities::members_request))
// developer
.route("/developer", get(developer::home_request))
.route("/developer/app/{id}", get(developer::app_request))
// stacks
.route("/stacks", get(stacks::list_request))
.route("/stacks/{id}", get(stacks::posts_request))

View file

@ -104,6 +104,22 @@ pub async fn settings_request(
.unwrap()
.replace("\"", "\\\""),
);
context.insert("profile_grants", &{
let mut out = Vec::new();
for grant in profile.grants {
out.push((
match data.0.get_app_by_id(grant.app).await {
Ok(a) => a,
// TODO: remove grant from user (app deleted)
Err(_) => continue,
},
grant,
));
}
out
});
// check color contrasts
let mut failing_color_keys: Vec<(&str, f64)> = Vec::new();