diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index ba7f4e3..e863b65 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -1135,15 +1135,13 @@
(icon (text "rabbit"))
(str (text "general:link.reference")))
- (a
- ("href" "{{ config.policies.terms_of_service }}")
- ("class" "button")
+ (button
+ ("onclick" "trigger('me::achievement', ['OpenTos']); Turbo.visit('{{ config.policies.terms_of_service }}')")
(icon (text "heart-handshake"))
(text "Terms of service"))
- (a
- ("href" "{{ config.policies.privacy }}")
- ("class" "button")
+ (button
+ ("onclick" "trigger('me::achievement', ['OpenPrivacyPolicy']); Turbo.visit('{{ config.policies.privacy }}')")
(icon (text "cookie"))
(text "Privacy policy"))
(b ("class" "title") (str (text "general:label.account")))
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index b191b8a..dce30d2 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -1522,7 +1522,7 @@
[
[
\"hide_associated_blocked_users\",
- \"Hide users that you've blocked on your other accounts from timelines.\",
+ \"Hide users that you've blocked on your other accounts from timelines\",
],
\"{{ profile.settings.hide_associated_blocked_users }}\",
\"checkbox\",
diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js
index e346a6b..0c7ac10 100644
--- a/crates/app/src/public/js/me.js
+++ b/crates/app/src/public/js/me.js
@@ -342,6 +342,27 @@
},
);
+ self.define("achievement", async (_, name) => {
+ fetch("/api/v1/auth/user/me/achievement", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name,
+ }),
+ })
+ .then((res) => res.json())
+ .then((res) => {
+ if (!res.ok) {
+ trigger("atto::toast", [
+ res.ok ? "success" : "error",
+ res.message,
+ ]);
+ }
+ });
+ });
+
self.define("report", (_, asset, asset_type) => {
window.open(
`/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`,
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index 1977d95..f66e9bf 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -3,8 +3,9 @@ use crate::{
get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{
- AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole,
- UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
+ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
+ UpdateSecondaryUserRole, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole,
+ UpdateUserUsername,
},
State,
};
@@ -21,7 +22,7 @@ use futures_util::{sink::SinkExt, stream::StreamExt};
use tetratto_core::{
cache::Cache,
model::{
- auth::{AchievementName, InviteCode, Token, UserSettings},
+ auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
moderation::AuditLogEntry,
oauth,
permissions::FinePermission,
@@ -920,3 +921,31 @@ pub async fn generate_invite_codes_request(
payload: Some((out_string, errors_string)),
})
}
+
+/// Award an achievement to the current user.
+/// Only works with specific "self-serve" achievements.
+pub async fn self_serve_achievement_request(
+ jar: CookieJar,
+ Extension(data): Extension,
+ Json(req): Json,
+) -> 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 !SELF_SERVE_ACHIEVEMENTS.contains(&req.name) {
+ return Json(Error::MiscError("Cannot grant this achievement manually".to_string()).into());
+ }
+
+ // award achievement
+ match data.add_achievement(&mut user, req.name.into()).await {
+ Ok(_) => Json(ApiReturn {
+ ok: true,
+ message: "Achievement granted".to_string(),
+ payload: (),
+ }),
+ Err(e) => Json(e.into()),
+ }
+}
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index b87dbdc..3cd4423 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -19,6 +19,7 @@ use axum::{
use serde::Deserialize;
use tetratto_core::model::{
apps::AppQuota,
+ auth::AchievementName,
communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
PollOption, PostContext,
@@ -387,6 +388,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/grants/{app}/refresh",
post(auth::profile::refresh_grant_request),
)
+ .route(
+ "/auth/user/me/achievement",
+ post(auth::profile::self_serve_achievement_request),
+ )
// apps
.route("/apps", post(apps::create_request))
.route("/apps/{id}/title", post(apps::update_title_request))
@@ -976,7 +981,13 @@ pub struct AddJournalDir {
pub struct RemoveJournalDir {
pub dir: String,
}
+
#[derive(Deserialize)]
pub struct UpdateNoteTags {
pub tags: Vec,
}
+
+#[derive(Deserialize)]
+pub struct AwardAchievement {
+ pub name: AchievementName,
+}
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index c2c68c1..bac4ae6 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -493,7 +493,10 @@ pub struct ExternalConnectionData {
}
/// The total number of achievements needed to 100% Tetratto!
-pub const ACHIEVEMENTS: usize = 28;
+pub const ACHIEVEMENTS: usize = 30;
+/// "self-serve" achievements can be granted by the user through the API.
+pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] =
+ &[AchievementName::OpenTos, AchievementName::OpenPrivacyPolicy];
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum AchievementName {
@@ -525,6 +528,8 @@ pub enum AchievementName {
EditNote,
CreatePostWithTitle,
CreateRepost,
+ OpenTos,
+ OpenPrivacyPolicy,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -565,6 +570,8 @@ impl AchievementName {
Self::EditNote => "I take it back!",
Self::CreatePostWithTitle => "Must declutter",
Self::CreateRepost => "More than a like or comment...",
+ Self::OpenTos => "Well informed!",
+ Self::OpenPrivacyPolicy => "Privacy conscious",
}
}
@@ -598,6 +605,8 @@ impl AchievementName {
Self::EditNote => "Edit a note.",
Self::CreatePostWithTitle => "Create a post with a title.",
Self::CreateRepost => "Create a repost or quote.",
+ Self::OpenTos => "Open the terms of service.",
+ Self::OpenPrivacyPolicy => "Open the privacy policy.",
}
}
@@ -633,6 +642,8 @@ impl AchievementName {
Self::EditNote => Uncommon,
Self::CreatePostWithTitle => Common,
Self::CreateRepost => Common,
+ Self::OpenTos => Uncommon,
+ Self::OpenPrivacyPolicy => Uncommon,
}
}
}
diff --git a/example/tetratto.toml b/example/tetratto.toml
index 488bc89..0f36100 100644
--- a/example/tetratto.toml
+++ b/example/tetratto.toml
@@ -38,8 +38,8 @@ user = "user"
password = "postgres"
[policies]
-terms_of_service = "/public/tos.html"
-privacy = "/public/privacy.html"
+terms_of_service = "/doc/tos.md"
+privacy = "/doc/privacy.md"
[turnstile]
site_key = "1x00000000000000000000AA"