Registration and activation

This commit is contained in:
Torsten Schulz
2024-07-20 20:43:18 +02:00
parent 3880a265eb
commit bbf4a2deb3
51 changed files with 3016 additions and 69 deletions

View File

@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"axios": "^1.7.2",
"dotenv": "^16.4.5",
"vue": "~3.4.31",
"vue-i18n": "^10.0.0-beta.2",
"vue-router": "^4.0.13",
@@ -762,6 +763,15 @@
}
}
},
"node_modules/@vue/cli-service/node_modules/dotenv": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz",
"integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@vue/cli-shared-utils": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/@vue/cli-shared-utils/-/cli-shared-utils-5.0.8.tgz",
@@ -2835,12 +2845,14 @@
}
},
"node_modules/dotenv": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz",
"integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==",
"dev": true,
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
"engines": {
"node": ">=10"
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {

View File

@@ -8,6 +8,7 @@
},
"dependencies": {
"axios": "^1.7.2",
"dotenv": "^16.4.5",
"vue": "~3.4.31",
"vue-i18n": "^10.0.0-beta.2",
"vue-router": "^4.0.13",

View File

@@ -1,7 +1,7 @@
<template>
<div id="app">
<AppHeader />
<AppNavigation v-if="isLoggedIn" />
<AppNavigation v-if="isLoggedIn && user.active" />
<AppContent />
<AppFooter />
</div>
@@ -20,14 +20,18 @@ export default {
document.title = 'yourPart';
},
computed: {
...mapGetters(['isLoggedIn'])
...mapGetters(['isLoggedIn', 'user'])
},
components: {
AppHeader,
AppNavigation,
AppContent,
AppFooter
}
},
created() {
this.$store.dispatch('loadLoginState');
this.$i18n.locale = this.$store.getters.language;
},
};
</script>
@@ -37,5 +41,4 @@ export default {
flex-direction: column;
height: 100%;
}
</style>

View File

@@ -46,4 +46,9 @@ button:hover {
.rc-partner {
color: #0000ff;
font-weight: bold;
}
.link {
color: #F9A22C;
cursor: pointer;
}

View File

@@ -43,12 +43,13 @@ export default {
<style lang="scss" scoped>
@import '../assets/styles.scss';
nav {
nav > ul{
display: flex;
justify-content: space-between;
background-color: #343a40;
color: white;
padding: 10px;
flex-direction: row;
}
ul {
list-style-type: none;

View File

@@ -1,9 +1,10 @@
<template>
<div v-if="visible" :class="['dialog-overlay', { 'non-modal': !modal }]" @click.self="handleOverlayClick">
<div class="dialog" :class="{ minimized: minimized }" :style="{ width: dialogWidth, height: dialogHeight }" v-if="!minimized">
<div class="dialog" :class="{ minimized: minimized }" :style="{ width: dialogWidth, height: dialogHeight }"
v-if="!minimized">
<div class="dialog-header">
<span v-if="icon" class="dialog-icon">
<img :src="'/images/icons/' + icon" alt="Icon" />
<img :src="icon" alt="Icon" />
</span>
<span class="dialog-title">{{ isTitleTranslated ? $t(title) : title }}</span>
<span v-if="!modal" class="dialog-minimize" @click="minimize">_</span>
@@ -13,9 +14,8 @@
<slot></slot>
</div>
<div class="dialog-footer">
<button v-for="button in buttons" :key="button.text" @click="buttonClick(button.text)"
class="dialog-button">
{{ button.text }}
<button v-for="button in buttons" :key="button.text" @click="buttonClick(button.action)" class="dialog-button">
{{ isTitleTranslated ? $t(button.text) : button.text }}
</button>
</div>
</div>
@@ -40,7 +40,7 @@ export default {
},
buttons: {
type: Array,
default: () => [{ text: 'Ok' }]
default: () => [{ text: 'Ok', action: 'close' }]
},
modal: {
type: Boolean,
@@ -87,7 +87,7 @@ export default {
methods: {
open() {
this.visible = true;
if (!this.modal) {
if (this.modal === false) {
this.$store.dispatch('dialogs/addOpenDialog', {
status: 'open',
dialog: this
@@ -98,9 +98,11 @@ export default {
this.visible = false;
this.$store.dispatch('dialogs/removeOpenDialog', this.name);
},
buttonClick(buttonText) {
this.$emit(buttonText.toLowerCase());
this.close();
buttonClick(action) {
this.$emit(action);
if (action === 'close') {
this.close(); // Close dialog after button click if action is close
}
},
handleOverlayClick() {
if (!this.modal) {
@@ -186,7 +188,7 @@ export default {
.dialog-body {
flex-grow: 1;
padding: 10px;
padding: 20px;
overflow-y: auto;
}
@@ -197,4 +199,18 @@ export default {
border-top: 1px solid #ddd;
}
.dialog-button {
margin-left: 10px;
padding: 10px 20px;
cursor: pointer;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
transition: background 0.3s;
}
.dialog-button:hover {
background: #0056b3;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<DialogWidget ref="dialog" title="passwordReset.title" :show-close=true :buttons="buttons" @close="closeDialog" name="PasswordReset">
<div>
<label>{{ $t("passwordReset.email") }} <input type="email" v-model="email" required /></label>
</div>
</DialogWidget>
</template>
<script>
import apiClient from '@/utils/axios.js';
import DialogWidget from '@/components/DialogWidget.vue';
export default {
name: 'PasswordResetDialog',
components: {
DialogWidget,
},
data() {
return {
email: '',
buttons: [{ text: this.$t("passwordReset.reset") }]
};
},
methods: {
open() {
this.$refs.dialog.open();
},
closeDialog() {
this.$refs.dialog.close();
},
async resetPassword() {
try {
await apiClient.post('/api/users/requestPasswordReset', {
email: this.email
});
this.$refs.dialog.close();
alert(this.$t("passwordReset.success"));
} catch (error) {
console.error('Error resetting password:', error);
alert(this.$t("passwordReset.failure"));
}
}
}
};
</script>

View File

@@ -0,0 +1,134 @@
<template>
<DialogWidget ref="dialog" title="register.title" :show-close="true" :buttons="buttons" :modal="true"
@close="closeDialog" @register="register" width="35em" height="33em" name="RegisterDialog"
:isTitleTranslated="true">
<div class="form-content">
<div>
<label>{{ $t("register.email") }}<input type="email" v-model="email" /></label>
</div>
<div>
<label>{{ $t("register.username") }}<input type="text" v-model="username" /></label>
</div>
<div>
<label>{{ $t("register.password") }}<input type="password" v-model="password" /></label>
</div>
<div>
<label>{{ $t("register.repeatPassword") }}<input type="password" v-model="repeatPassword" /></label>
</div>
<div>
<label>{{ $t("register.language") }}<select v-model="language">
<option value="en">{{ $t("register.languages.en") }}</option>
<option value="de">{{ $t("register.languages.de") }}</option>
</select></label>
</div>
</div>
<ErrorDialog ref="errorDialog" />
</DialogWidget>
</template>
<script>
import { mapActions } from 'vuex';
import apiClient from '@/utils/axios.js';
import DialogWidget from '@/components/DialogWidget.vue';
import ErrorDialog from '@/dialogues/standard/ErrorDialog.vue';
export default {
name: 'RegisterDialog',
components: {
DialogWidget,
ErrorDialog,
},
data() {
return {
email: '',
username: '',
password: '',
repeatPassword: '',
language: this.getBrowserLanguage(),
buttons: [
{ text: 'register.close', action: 'close' },
{ text: 'register.register', action: 'register', disabled: !this.canRegister }
]
};
},
computed: {
canRegister() {
return this.password && this.repeatPassword && this.password === this.repeatPassword;
}
},
watch: {
canRegister(newValue) {
this.buttons[1].disabled = !newValue;
}
},
methods: {
...mapActions(['login']),
getBrowserLanguage() {
const browserLanguage = navigator.language || navigator.languages[0];
if (browserLanguage.startsWith('de')) {
return 'de';
} else {
return 'en';
}
},
open() {
this.$refs.dialog.open();
},
closeDialog() {
this.$refs.dialog.close();
},
async register() {
if (!this.canRegister) {
console.log('pw-fehler');
this.$refs.errorDialog.open('tr:register.passwordMismatch');
return;
}
try {
const response = await apiClient.post('/api/auth/register', {
email: this.email,
username: this.username,
password: this.password,
language: this.language
});
if (response.status === 201) {
console.log(response.data);
this.login(response.data);
this.$refs.dialog.close();
this.$router.push('/activate');
} else {
this.$refs.errorDialog.open("tr:register.failure");
}
} catch (error) {
if (error.response && error.response.status === 409) {
this.$refs.errorDialog.open('tr:register.' + error.response.data.error);
} else {
console.error('Error registering user:', error);
this.$refs.errorDialog.open('tr:register.' + error.response.data.error);
}
}
}
}
};
</script>
<style scoped>
.form-content>div {
margin-bottom: 1em;
}
label {
display: block;
margin-bottom: 0.5em;
}
input[type="email"],
input[type="text"],
input[type="password"],
select {
width: 100%;
padding: 0.5em;
box-sizing: border-box;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<DialogWidget ref="dialog" title="randomchat.title" icon="dice24.png" :show-close="true" :buttons="buttons"
:modal="false" :isTitleTranslated="true" @close="closeDialog">
<DialogWidget ref="dialog" title="randomchat.title" icon="dice24.png" :show-close=true :buttons="buttons"
:modal=false :isTitleTranslated=true @close="closeDialog" name="RandomChat">
<div v-if="chatIsRunning" class="randomchat">
<div class="headline">
{{ $t("randomchat.agerange") }}

View File

@@ -2,11 +2,11 @@
<DialogWidget
ref="dialog"
title="dataPrivacy.title"
isTitleTranslated=true
:isTitleTranslated=true
icon="privacy24.png"
:show-close="true"
:show-close=true
:buttons="[{ text: 'Ok' }]"
:modal="false"
:modal=false
@close="closeDialog"
@ok="handleOk"
>

View File

@@ -0,0 +1,52 @@
<template>
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="25em"
height="15em" name="ErrorDialog" :isTitleTranslated=true>
<div class="error-content">
<p>{{ translatedErrorMessage }}</p>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
export default {
name: 'ErrorDialog',
components: {
DialogWidget,
},
data() {
return {
errorMessage: '',
buttons: [
{ text: 'error.close', action: 'close' }
]
};
},
computed: {
translatedErrorMessage() {
if (this.errorMessage.startsWith('tr:')) {
return this.$t(this.errorMessage.substring(3));
}
return this.errorMessage;
}
},
methods: {
open(message) {
this.errorMessage = message;
this.$refs.dialog.open();
},
close() {
this.$refs.dialog.close();
}
}
};
</script>
<style scoped>
.error-content {
padding: 1em;
color: red;
text-align: center;
}
</style>

View File

@@ -2,11 +2,11 @@
<DialogWidget
ref="dialog"
title="imprint.title"
isTitleTranslated=true
:isTitleTranslated=true
icon="imprint24.png"
:show-close="true"
:buttons="[{ text: 'Ok' }]"
:modal="false"
:modal=false
@close="closeDialog"
@ok="handleOk"
>

View File

@@ -1,16 +1,23 @@
import { createI18n } from 'vue-i18n';
import store from '../store/index.js';
import enGeneral from './locales/en/general.json';
import enHeader from './locales/en/header.json';
import enNavigation from './locales/en/navigation.json';
import enHome from './locales/en/home.json';
import enChat from './locales/en/chat.json';
import enRegister from './locales/en/register.json';
import enError from './locales/en/error.json';
import enActivate from './locales/en/activate.json';
import deGeneral from './locales/de/general.json';
import deHeader from './locales/de/header.json';
import deNavigation from './locales/de/navigation.json';
import deHome from './locales/de/home.json';
import deChat from './locales/de/chat.json';
import deRegister from './locales/de/register.json';
import deError from './locales/de/error.json';
import deActivate from './locales/de/activate.json';
const messages = {
en: {
@@ -19,6 +26,9 @@ const messages = {
...enNavigation,
...enHome,
...enChat,
...enRegister,
...enError,
...enActivate,
},
de: {
...deGeneral,
@@ -26,11 +36,14 @@ const messages = {
...deNavigation,
...deHome,
...deChat,
...deRegister,
...deError,
...deActivate,
}
};
const i18n = createI18n({
locale: 'de',
locale: store.state.language,
fallbackLocale: 'de',
messages
});

View File

@@ -0,0 +1,9 @@
{
"activate": {
"title": "Zugang aktivieren",
"message": "Hallo {username}. Bitte gib hier den Code ein, den wir Dir per Email zugesendet haben.",
"token": "Token:",
"submit": "Absenden",
"failure": "Die Aktivierung war nicht erfolgreich."
}
}

View File

@@ -0,0 +1,6 @@
{
"error": {
"title": "Fehler aufgetreten",
"close": "Schließen"
}
}

View File

@@ -9,7 +9,9 @@
"name": "Login-Name",
"namedescription": "Gib hier Deinen Benutzernamen ein",
"password": "Paßwort",
"passworddescription": "Gib hier Dein Paßwort ein"
"passworddescription": "Gib hier Dein Paßwort ein",
"lostpassword": "Paßwort vergessen",
"register": "Bei yourPart registrieren"
}
}
}

View File

@@ -0,0 +1,21 @@
{
"register": {
"title": "Bei yourPart registrieren",
"email": "Email-Adresse",
"username": "Benutzername",
"password": "Paßwort",
"repeatPassword": "Paßwort wiederholen",
"language": "Sprache",
"languages": {
"en": "Englisch",
"de": "Deutsch"
},
"register": "Registrieren",
"close": "Schließen",
"failure": "Es ist ein Fehler aufgetreten.",
"success": "Du wurdest erfolgreich registriert. Bitte schaue jetzt in Dein Email-Postfach zum aktivieren Deines Zugangs.",
"passwordMismatch": "Die Paßwörter stimmen nicht überein.",
"emailinuse": "Die Email-Adresse wird bereits verwendet.",
"usernameinuse": "Der Benutzername ist nicht verfügbar."
}
}

View File

@@ -0,0 +1,3 @@
{
}

View File

@@ -0,0 +1,5 @@
{
"error": {
}
}

View File

@@ -2,7 +2,7 @@
"home": {
"nologin": {
"welcome": "Welcome at yourPart",
"description": "<platzhalter>",
"description": "---platzhalter---",
"randomchat": "Random chat",
"startrandomchat": "Start random chat"
}

View File

@@ -0,0 +1,3 @@
{
}

View File

@@ -5,6 +5,18 @@ import router from './router';
import './assets/styles.scss';
import i18n from './i18n';
function getBrowserLanguage() {
const browserLanguage = navigator.language || navigator.languages[0];
console.log(browserLanguage);
if (browserLanguage.startsWith('de')) {
return 'de';
} else {
return 'en';
}
}
store.dispatch('setLanguage', getBrowserLanguage());
const app = createApp(App);
app.use(store);

View File

@@ -1,11 +1,17 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import ActivateView from '../views/auth/ActivateView.vue';
const routes = [
{
path: '/',
name: 'Home',
component: HomeView
},
{
path: '/activate',
name: 'Activate page',
component: ActivateView
}
];
@@ -18,8 +24,10 @@ router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!store.getters.isLoggedIn) {
next('/');
} else {
} else if (!store.user.active) {
next();
} else {
next('/activate');
}
} else {
next();

View File

@@ -4,16 +4,17 @@ import dialogs from './modules/dialogs';
const store = createStore({
state: {
isLoggedIn: false,
user: null
user: null,
language: navigator.language.startsWith('de') ? 'de' : 'en',
},
mutations: {
login(state, user) {
dologin(state, user) {
state.isLoggedIn = true;
state.user = user;
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('user', JSON.stringify(user));
},
logout(state) {
dologout(state) {
state.isLoggedIn = false;
state.user = null;
localStorage.removeItem('isLoggedIn');
@@ -30,22 +31,29 @@ const store = createStore({
const user = userData;
state.isLoggedIn = isLoggedIn;
state.user = user;
},
setLanguage(state, language) {
state.language = language;
}
},
actions: {
login({ commit }, user) {
commit('login', user);
commit('dologin', user);
},
logout({ commit }) {
commit('logout');
commit('dologout');
},
loadLoginState({ commit }) {
commit('loadLoginState');
}
},
setLanguage({ commit }, language) {
commit('setLanguage', language);
},
},
getters: {
isLoggedIn: state => state.isLoggedIn,
user: state => state.user
user: state => state.user,
language: state => state.language,
},
modules: {
dialogs,

View File

@@ -0,0 +1,10 @@
import axios from 'axios';
const apiClient = axios.create({
baseURL: process.env.VUE_APP_API_BASE_URL || 'http://localhost:3001',
headers: {
'Content-Type': 'application/json'
}
});
export default apiClient;

View File

@@ -0,0 +1,95 @@
<template>
<div class="activate-container">
<h1>{{ $t('activate.title') }}</h1>
<p v-if="user">{{ $t('activate.message', { username: user.username }) }}</p>
<form @submit.prevent="activateAccount">
<div>
<label>{{ $t('activate.token') }}</label>
<input type="text" v-model="token" required />
</div>
<div>
<button type="submit">{{ $t('activate.submit') }}</button>
</div>
</form>
<ErrorDialog ref="errorDialog" />
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
import ErrorDialog from '@/dialogues/standard/ErrorDialog.vue';
export default {
name: 'ActivateView',
data() {
return {
token: this.$route.query.token || ''
};
},
components: {
ErrorDialog,
},
computed: {
...mapGetters(['user'])
},
methods: {
async activateAccount() {
try {
const response = await apiClient.post('/api/auth/activate', { token: this.token });
if (response.status === 200) {
this.user.active = true;
this.$router.push('/'); // Redirect to login after activation
}
} catch (error) {
console.error('Error activating account:', error);
this.$refs.errorDialog.open(this.$t('activate.failure'));
}
}
}
};
</script>
<style scoped>
.activate-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 2em;
box-sizing: border-box;
}
form {
display: flex;
flex-direction: column;
align-items: center;
}
label {
display: block;
margin-bottom: 0.5em;
}
input[type="text"] {
width: 100%;
padding: 0.5em;
margin-bottom: 1em;
box-sizing: border-box;
}
button {
padding: 0.5em 1em;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background-color: #0056b3;
}
</style>

View File

@@ -2,12 +2,22 @@
<div>
<h1>Welcome to Home (Logged In)</h1>
<p>Here are your exclusive features.</p>
<button @click="handleLogout">Logout</button>
</div>
</template>
<script>
import { mapActions } from 'vuex';
export default {
name: 'HomeLoggedInView',
methods: {
...mapActions(['logout']),
handleLogout() {
this.logout();
}
}
};
</script>

View File

@@ -12,18 +12,19 @@
<div>
<div>
<input data-object-name="user-name" size="20" type="text"
:placeholder="$t('home.nologin.login.name')" :title="$t('home.nologin.login.namedescription')">
:placeholder="$t('home.nologin.login.name')"
:title="$t('home.nologin.login.namedescription')">
</div>
<div>
<input data-object-name="password" size="20" type="password"
:placeholder="$t('home.nologin.login.password')" :title="$t('home.nologin.login.passworddescription')">
:placeholder="$t('home.nologin.login.password')"
:title="$t('home.nologin.login.passworddescription')">
</div>
<div>
<label id="o1p5irxv" name="o1p5irxv" class="Wt-valid" title=""><input id="ino1p5irxv"
data-object-name="remember-me" name="ino1p5irxv" type="checkbox"
onchange="var e=event||window.event,o=this;Wt._p_.update(o,'s53',e,true);"><span
id="to1p5irxv" name="to1p5irxv" style="white-space:normal;">Eingeloggt bleiben
(ACHTUNG!!! Dafür wird ein Cookie gesetzt!)</span></label>
id="to1p5irxv" name="to1p5irxv" style="white-space:normal;">Eingeloggt bleiben</span></label>
</div>
</div>
<div class="Wt-buttons">
@@ -32,30 +33,41 @@
class="Wt-btn with-label">Einloggen</button>
</div>
<div class="Wt-buttons">
<span id="o1p5iry0" data-object-name="lost-password"
onclick="var e=event||window.event,o=this;if(o.classList.contains('Wt-disabled')){Wt4_9_1.cancelEvent(e);return;}Wt._p_.update(o,'s57',e,true);">Ich
habe mein Paßwort vergessen</span> | <span id="o1p5iry1" data-object-name="register"
onclick="var e=event||window.event,o=this;if(o.classList.contains('Wt-disabled')){Wt4_9_1.cancelEvent(e);return;}Wt._p_.update(o,'s58',e,true);">Ich
möchte mich neu anmelden</span>
<span id="o1p5iry0" data-object-name="lost-password" @click="openPasswordResetDialog"
class="link">{{
$t('home.nologin.login.lostpassword') }}</span> | <span id="o1p5iry1"
@click="openRegisterDialog" class="link">{{ $t('home.nologin.login.register') }}</span>
</div>
</div>
</div>
<div class="mascot"><img src="/images/mascot/mascot_female.png" /></div>
<RandomChatDialog ref="randomChatDialog" />
<RegisterDialog ref="registerDialog" />
<PasswordResetDialog ref="passwordResetDialog" />
</div>
</template>
<script>
import RandomChatDialog from '@/dialogues/chat/RandomChatDialog.vue';
import RegisterDialog from '@/dialogues/auth/RegisterDialog.vue';
import PasswordResetDialog from '@/dialogues/auth/PasswordResetDialog.vue';
export default {
export default {
name: 'HomeNoLoginView',
components: {
RandomChatDialog,
RegisterDialog,
PasswordResetDialog,
},
methods: {
openRandomChat() {
this.$refs.randomChatDialog.open();
},
openRegisterDialog() {
this.$refs.registerDialog.open();
},
openPasswordResetDialog() {
this.$refs.passwordResetDialog.open();
}
}
};
@@ -68,33 +80,38 @@ export default {
justify-content: center;
overflow: hidden;
gap: 2em;
height:100%;
height: 100%;
}
.home-structure > div {
.home-structure>div {
flex: 1;
text-align: center;
display: flex;
}
.mascot {
justify-content: center;
align-items: center;
background-color: #fdf1db;
}
.actions {
display: flex;
flex-direction: column;
gap: 2em;
}
.actions > div {
.actions>div {
flex: 1;
background-color: #fdf1db;
align-items: center;
justify-content:center;
justify-content: center;
display: flex;
color: #7E471B;
flex-direction: column;
}
.actions > div > h2 {
.actions>div>h2 {
color: #F9A22C;
}
</style>
</style>