= { emptyArray() },
+): FC = FC { props ->
+ require(useServerPaging xor (props.getPageCount == null)) {
+ "Either use client-side paging or provide a function to get page count"
+ }
+ val (data, setData) = useState>(emptyArray())
+ val (pageCount, setPageCount) = useState(1)
+ val (pageIndex, setPageIndex) = useState(0)
+ val (isModalOpen, setIsModalOpen) = useState(false)
+ val (dataAccessException, setDataAccessException) = useState(null)
+ val scope = CoroutineScope(Dispatchers.Default)
+
+ val (sorting, setSorting) = useState(emptyArray())
+ val tableInstance: Table = useReactTable(options = jso> {
+ this.columns = useMemo { columns(props) }
+ this.data = data
+ this.getCoreRowModel = getCoreRowModel()
+ this.manualPagination = useServerPaging
+ if (useServerPaging) {
+ this.pageCount = pageCount
+ }
+ this.initialState = jso {
+ this.pagination = jso {
+ this.pageSize = initialPageSize
+ this.pageIndex = pageIndex
+ }
+ this.sorting = sorting
+ }
+ this.asDynamic().state = jso {
+ // Apparently, setting `initialState` is not enough and examples from tanstack-react-table use `state` in `TableOptions`.
+ // It's not present in kotlin-wrappers v.423 though.
+ this.sorting = sorting
+ }
+ this.onSortingChange = { updater ->
+ setSorting.invoke(updater)
+ }
+ this.getSortedRowModel = getSortedRowModel()
+ this.getPaginationRowModel = tanstack.table.core.getPaginationRowModel()
+ additionalOptions()
+ }.also { tableOptionsCustomizer(it) })
+
+ // list of entities, updates of which will cause update of the data retrieving effect
+ val dependencies: Array = if (useServerPaging) {
+ arrayOf(tableInstance.getState().pagination.pageIndex, tableInstance.getState().pagination.pageSize, pageCount)
+ } else {
+ // when all data is already available, we don't need to repeat `getData` calls
+ emptyArray()
+ } + getAdditionalDependencies(props)
+
+ useEffect(*dependencies) {
+ if (useServerPaging) {
+ scope.launch {
+ val newPageCount = props.getPageCount!!.invoke(tableInstance.getState().pagination.pageSize)
+ if (newPageCount != pageCount) {
+ setPageCount(newPageCount)
+ }
+ }
+ }
+ }
+
+ val context = useRequestStatusContext()
+
+ useEffect(*dependencies) {
+ scope.launch {
+ try {
+ setData(context.(props.getData)(
+ tableInstance.getState().pagination.pageIndex, tableInstance.getState().pagination.pageSize
+ ))
+ } catch (e: CancellationException) {
+ // this means, that view is re-rendering while network request was still in progress
+ // no need to display an error message in this case
+ } catch (e: HttpStatusException) {
+ // this is a normal situation which should be handled by responseHandler in `getData` itself.
+ // no need to display an error message in this case
+ } catch (e: Exception) {
+ // other exceptions are not handled by `responseHandler` and should be displayed separately
+ setIsModalOpen(true)
+ setDataAccessException(e)
+ }
+ }
+ cleanup {
+ if (scope.isActive) {
+ scope.cancel()
+ }
+ }
+ }
+
+ val navigate = useNavigate()
+
+ val commonHeader = useMemo {
+ Fragment.create {
+ props.commonHeaderBuilder?.invoke(
+ this,
+ tableInstance,
+ navigate
+ )
+ }
+ }
+
+ div {
+ className = ClassName("${if (isTransparentGrid) "" else "card shadow"} mb-4")
+ if (props.tableHeader != undefined) {
+ div {
+ className = ClassName("card-header py-3")
+ h6 {
+ className = ClassName("m-0 font-weight-bold text-primary text-center")
+ +props.tableHeader
+ }
+ }
+ }
+ div {
+ className = ClassName("${props.cardBodyClassName} card-body")
+ div {
+ className = ClassName("table-responsive")
+ // we sometimes have strange overflow with some monitor resolution in chrome
+ style = jso {
+ overflowX = Overflow.hidden
+ }
+ table {
+ className = ClassName("table ${if (isTransparentGrid) "" else "table-bordered"} mb-0")
+ width = 100.0
+ cellSpacing = "0"
+ thead {
+ +commonHeader
+ tableInstance.getHeaderGroups().map { headerGroup ->
+ tr {
+ id = headerGroup.id
+ headerGroup.headers.map { header: Header ->
+ val column = header.column
+ th {
+ className = ClassName("m-0 font-weight-bold text-center text-nowrap")
+ +renderHeader(header)
+ if (column.getCanSort()) {
+ style = style ?: jso()
+ style?.cursor = "pointer".unsafeCast()
+ span {
+ +when (column.getIsSorted()) {
+ SortDirection.asc -> " 🔽"
+ SortDirection.desc -> " 🔼"
+ else -> ""
+ }
+ }
+ onClick = column.getToggleSortingHandler()
+ }
+ }
+ }
+ }
+ }
+ }
+ tbody {
+ tableInstance.getRowModel().rows.map { row ->
+ tr {
+ spread(getRowProps(row))
+ row.getVisibleCells().map { cell ->
+ +renderCell(cell)
+ }
+ }
+ if (row.isExpanded) {
+ requireNotNull(renderExpandedRow) {
+ "`useExpanded` is used, but no method for expanded row is provided"
+ }
+ renderExpandedRow.invoke(this@tbody, tableInstance, row)
+ }
+ }
+ }
+ }
+
+ if (data.isEmpty()) {
+ div {
+ className = ClassName("col mt-4 mb-4")
+ div {
+ className = ClassName("row justify-content-center")
+ h6 {
+ className = ClassName("m-0 mt-3 font-weight-bold text-primary text-center")
+ +"Nothing was found"
+ }
+ }
+ div {
+ className = ClassName("row justify-content-center")
+ img {
+ src = "/img/sad_cat.png"
+ @Suppress("MAGIC_NUMBER")
+ style = jso {
+ width = 14.rem
+ }
+ }
+ }
+ }
+ }
+
+ if (tableInstance.getPageCount() > 1) {
+ div {
+ className = ClassName("wrapper container m-0 p-0 mt-2")
+ pagingControl(tableInstance, setPageIndex, pageIndex, pageCount, initialPageSize)
+
+ div {
+ className = ClassName("row ml-1")
+ +"Page "
+ em {
+ className = ClassName("ml-1")
+ +" ${tableInstance.getState().pagination.pageIndex + 1} of ${tableInstance.getPageCount()}"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ displayModal(
+ isModalOpen,
+ "Error",
+ "Error when fetching data: ${dataAccessException?.message}",
+ mediumTransparentModalStyle,
+ { setIsModalOpen(false) },
+ ) {
+ buttonBuilder("Close", "secondary") {
+ setIsModalOpen(false)
+ }
+ }
+}
diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/TableUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/TableUtils.kt
new file mode 100644
index 0000000000..0f51469dc7
--- /dev/null
+++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/TableUtils.kt
@@ -0,0 +1,58 @@
+@file:Suppress(
+ "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE",
+ "CUSTOM_GETTERS_SETTERS",
+ "MISSING_KDOC_ON_FUNCTION",
+ "MISSING_KDOC_TOP_LEVEL",
+)
+
+package com.saveourtool.save.frontend.common.components.tables
+
+import js.core.jso
+import react.ChildrenBuilder
+import react.StateSetter
+import react.useState
+import tanstack.table.core.CellContext
+import tanstack.table.core.ExpandedState
+import tanstack.table.core.Row
+import tanstack.table.core.RowData
+import tanstack.table.core.Table
+import tanstack.table.core.TableOptions
+import tanstack.table.core.TableState
+import tanstack.table.core.Updater
+import tanstack.table.core.getExpandedRowModel
+
+val CellContext.value: TValue get() = this.getValue()
+
+val CellContext.pageIndex get() = this.table.getState()
+ .pagination
+ .pageIndex
+
+val CellContext.pageSize get() = this.table.getState()
+ .pagination
+ .pageSize
+
+val Row.isExpanded get() = getIsExpanded()
+
+val Table.canPreviousPage get() = getCanPreviousPage()
+
+val Table.canNextPage get() = getCanNextPage()
+
+fun Table.visibleColumnsCount() = this.getVisibleFlatColumns().size
+
+fun StateSetter.invoke(updaterOrValue: Updater) =
+ if (jsTypeOf(updaterOrValue) == "function") {
+ this.invoke(updaterOrValue.unsafeCast<(T) -> T>())
+ } else {
+ this.invoke(updaterOrValue.unsafeCast())
+ }
+
+fun ChildrenBuilder.enableExpanding(tableOptions: TableOptions) {
+ val (expanded, setExpanded) = useState(jso())
+ tableOptions.initialState!!.expanded = expanded
+ tableOptions.asDynamic()
+ .state
+ .unsafeCast()
+ .expanded = expanded
+ tableOptions.onExpandedChange = { setExpanded.invoke(it) }
+ tableOptions.getExpandedRowModel = getExpandedRowModel()
+}
diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AboutUsView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AboutUsView.kt
new file mode 100644
index 0000000000..a39eb30db6
--- /dev/null
+++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AboutUsView.kt
@@ -0,0 +1,240 @@
+/**
+ * View with some info about core team
+ */
+
+package com.saveourtool.save.frontend.common.components.views
+
+import com.saveourtool.save.frontend.common.components.RequestStatusContext
+import com.saveourtool.save.frontend.common.components.basic.cardComponent
+import com.saveourtool.save.frontend.common.components.basic.markdown
+import com.saveourtool.save.frontend.common.components.requestStatusContext
+import com.saveourtool.save.frontend.common.externals.fontawesome.faGithub
+import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon
+import com.saveourtool.save.frontend.common.utils.particles
+
+import js.core.jso
+import react.*
+import react.dom.html.ReactHTML.a
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.h2
+import react.dom.html.ReactHTML.h4
+import react.dom.html.ReactHTML.h5
+import react.dom.html.ReactHTML.h6
+import react.dom.html.ReactHTML.img
+import web.cssom.ClassName
+import web.cssom.Color
+import web.cssom.rem
+
+/**
+ * [Props] of [AboutUsView]
+ */
+external interface AboutUsViewProps : Props
+
+/**
+ * [State] of [AboutUsView]
+ */
+external interface AboutUsViewState : State
+
+/**
+ * A component representing "About us" page
+ */
+@JsExport
+@OptIn(ExperimentalJsExport::class)
+open class AboutUsView : AbstractView() {
+ private val developers = listOf(
+ Developer("Vlad", "Frolov", "Cheshiriks", "Fullstack"),
+ Developer("Peter", "Trifanov", "petertrr", "Arch"),
+ Developer("Andrey", "Shcheglov", "0x6675636b796f75676974687562", "Backend"),
+ Developer("Sasha", "Frolov", "sanyavertolet", "Fullstack"),
+ Developer("Andrey", "Kuleshov", "akuleshov7", "Ideas 😎"),
+ Developer("Nariman", "Abdullin", "nulls", "Fullstack"),
+ Developer("Alexey", "Votintsev", "Arrgentum", "Frontend"),
+ Developer("Kirill", "Gevorkyan", "kgevorkyan", "Backend"),
+ Developer("Dmitriy", "Morozovsky", "icemachined", "Sensei"),
+ ).sortedBy { it.name }
+
+ /**
+ * padding is removed for this card, because of the responsive images (avatars)
+ */
+ protected val devCard = cardComponent(hasBg = true, isPaddingBottomNull = true)
+
+ /**
+ * card with an info about SAVE with padding
+ */
+ protected val infoCard = cardComponent(hasBg = true, isPaddingBottomNull = true, isNoPadding = false)
+
+ override fun ChildrenBuilder.render() {
+ particles()
+ renderViewHeader()
+ renderSaveourtoolInfo()
+ renderDevelopers(NUMBER_OF_COLUMNS)
+ }
+
+ /**
+ * Simple title above the information card
+ */
+ protected fun ChildrenBuilder.renderViewHeader() {
+ h2 {
+ className = ClassName("text-center mt-3")
+ style = jso {
+ color = Color("#FFFFFF")
+ }
+ +"About us"
+ }
+ }
+
+ /**
+ * Info rendering
+ */
+ protected open fun ChildrenBuilder.renderSaveourtoolInfo() {
+ div {
+ div {
+ className = ClassName("mt-3 d-flex justify-content-center align-items-center")
+ div {
+ className = ClassName("col-6 p-0")
+ infoCard {
+ div {
+ className = ClassName("m-2 d-flex justify-content-around align-items-center")
+ div {
+ className = ClassName("m-2 d-flex align-items-center align-self-stretch flex-column")
+ img {
+ src = "/img/save-logo-no-bg.png"
+ @Suppress("MAGIC_NUMBER")
+ style = jso {
+ width = 8.rem
+ }
+ className = ClassName("img-fluid mt-auto mb-auto")
+ }
+ a {
+ className = ClassName("text-center mt-auto mb-2 align-self-end")
+ href = "mailto:$SAVEOURTOOL_EMAIL"
+ +SAVEOURTOOL_EMAIL
+ }
+ }
+ markdown(saveourtoolDescription, "flex-wrap")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @param columns
+ */
+ @Suppress("MAGIC_NUMBER")
+ protected fun ChildrenBuilder.renderDevelopers(columns: Int) {
+ div {
+ h4 {
+ className = ClassName("text-center mb-1 mt-4 text-white")
+ +"Active contributors"
+ }
+ div {
+ className = ClassName("mt-3 d-flex justify-content-around align-items-center")
+ div {
+ className = ClassName("col-6 p-1")
+ val numberOfRows = developers.size / columns
+ for (rowIndex in 0..numberOfRows) {
+ div {
+ className = ClassName("row")
+ for (colIndex in 0 until columns) {
+ div {
+ className = ClassName("col-${12 / columns} p-2")
+ developers.getOrNull(columns * rowIndex + colIndex)?.let {
+ renderDeveloperCard(it)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @param developer
+ */
+ open fun ChildrenBuilder.renderDeveloperCard(developer: Developer) {
+ devCard {
+ div {
+ className = ClassName("p-3")
+ div {
+ className = ClassName("d-flex justify-content-center")
+ img {
+ src = "$GITHUB_AVATAR_LINK${developer.githubNickname}?size=$DEFAULT_AVATAR_SIZE"
+ className = ClassName("img-fluid border border-dark rounded-circle m-0")
+ @Suppress("MAGIC_NUMBER")
+ style = jso {
+ width = 10.rem
+ }
+ }
+ }
+ div {
+ className = ClassName("mt-2")
+ h5 {
+ className = ClassName("d-flex justify-content-center text-center")
+ +developer.name
+ }
+ h5 {
+ className = ClassName("d-flex justify-content-center text-center")
+ +developer.surname
+ }
+ h6 {
+ className = ClassName("text-center")
+ +developer.description
+ }
+ a {
+ style = jso {
+ fontSize = 2.rem
+ }
+ className = ClassName("d-flex justify-content-center")
+ href = "$GITHUB_LINK${developer.githubNickname}"
+ fontAwesomeIcon(faGithub)
+ }
+ }
+ }
+ }
+ }
+
+ companion object :
+ RStatics>(AboutUsView::class) {
+ protected const val DEFAULT_AVATAR_SIZE = "200"
+ protected const val GITHUB_AVATAR_LINK = "https://avatars.githubusercontent.com/"
+ protected const val GITHUB_LINK = "https://github.com/"
+ protected const val MAX_NICKNAME_LENGTH = 15
+ protected const val NUMBER_OF_COLUMNS = 3
+ protected const val SAVEOURTOOL_EMAIL = "saveourtool@gmail.com"
+ protected val saveourtoolDescription = """
+ # Save Our Tool!
+
+ Our community is mainly focused on Static Analysis tools and the eco-system related to such kind of tools.
+ We love Kotlin and mostly everything we develop is connected with Kotlin JVM, Kotlin JS or Kotlin Native.
+
+ ### Main Repositories:
+ - [diktat](${GITHUB_LINK}saveourtool/diktat) - Automated code checker&fixer for Kotlin
+ - [save-cli](${GITHUB_LINK}saveourtool/save-cli) - Unified test framework for Static Analyzers and Compilers
+ - [save-cloud](${GITHUB_LINK}saveourtool/save-cloud) - Cloud eco-system for CI/CD and benchmarking of Static Analyzers
+ - [awesome-benchmarks](${GITHUB_LINK}saveourtool/awesome-benchmarks) - Curated list of benchmarks for different types of testing
+
+ """.trimIndent()
+
+ init {
+ AboutUsView.contextType = requestStatusContext
+ }
+ }
+}
+
+/**
+ * @property name developer's name
+ * @property githubNickname nickname of developer on GitHub
+ * @property description brief developer description
+ * @property surname
+ */
+@JsExport
+data class Developer(
+ val name: String,
+ val surname: String,
+ val githubNickname: String,
+ val description: String = "",
+)
diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AbstractView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AbstractView.kt
new file mode 100644
index 0000000000..1b914f03c2
--- /dev/null
+++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AbstractView.kt
@@ -0,0 +1,52 @@
+package com.saveourtool.save.frontend.common.components.views
+
+import com.saveourtool.save.frontend.common.utils.ComponentWithScope
+import com.saveourtool.save.frontend.common.utils.Style
+
+import react.*
+
+import kotlinx.browser.document
+
+/**
+ * Abstract view class that should be used in all functional views
+ */
+abstract class AbstractView(private val style: Style = Style.SAVE_DARK) : ComponentWithScope
() {
+ // A small hack to avoid duplication of main content-wrapper from App.kt
+ // We will have custom background only for sign-up and sign-in views
+ override fun componentDidMount() {
+ document.getElementById("main-body")?.apply {
+ className = when (style) {
+ Style.SAVE_DARK, Style.SAVE_LIGHT -> className.replace("vuln", "save")
+ Style.VULN_DARK, Style.VULN_LIGHT -> className.replace("save", "vuln")
+ Style.INDEX -> className.replace("vuln", "save")
+ }
+ }
+
+ document.getElementById("content-wrapper")?.setAttribute(
+ "style",
+ "background: ${style.globalBackground}"
+ )
+
+ configureTopBar(style)
+ }
+
+ private fun configureTopBar(style: Style) {
+ val topBar = document.getElementById("navigation-top-bar")
+ topBar?.setAttribute(
+ "class",
+ "navbar navbar-expand ${style.topBarBgColor} navbar-dark topbar ${style.marginBottomForTopBar} " +
+ "static-top shadow mr-1 ml-1 rounded"
+ )
+
+ topBar?.setAttribute(
+ "style",
+ "background: ${style.topBarTransparency}"
+ )
+
+ val container = document.getElementById("common-save-container")
+ container?.setAttribute(
+ "class",
+ "container-fluid ${style.borderForContainer}"
+ )
+ }
+}
diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/FallbackView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/FallbackView.kt
new file mode 100644
index 0000000000..029df85b93
--- /dev/null
+++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/FallbackView.kt
@@ -0,0 +1,113 @@
+/**
+ * Support rendering something as a fallback
+ */
+
+package com.saveourtool.save.frontend.common.components.views
+
+import com.saveourtool.save.frontend.common.utils.Style
+import com.saveourtool.save.frontend.common.utils.buttonBuilder
+
+import js.core.jso
+import react.*
+import react.dom.html.ReactHTML.a
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.img
+import react.dom.html.ReactHTML.p
+import react.router.Navigate
+import web.cssom.*
+
+import kotlinx.browser.document
+import kotlinx.browser.window
+
+/**
+ * Props of fallback component
+ */
+external interface FallbackViewProps : Props {
+ /**
+ * Text displayed in big letters
+ */
+ var bigText: String?
+
+ /**
+ * Small text for more vebose description
+ */
+ var smallText: String?
+
+ /**
+ * Whether link to the start page should be a `` (if false) or react-routers `Link` (if true).
+ * If this component is placed outside react-router's Router, then `Link` will be inaccessible.
+ */
+ var withRouterLink: Boolean?
+}
+
+/**
+ * A Component representing fallback page with 404 error
+ */
+@JsExport
+@OptIn(ExperimentalJsExport::class)
+class FallbackView : AbstractView(Style.SAVE_LIGHT) {
+ @Suppress("ForbiddenComment")
+ override fun ChildrenBuilder.render() {
+ // FixMe: not able to use "remove()" here due to some internal problem
+ // FixMe: or fix links
+ // so removing top bar for fallback view with a small hack
+ document.getElementById("navigation-top-bar")
+ ?.setAttribute("style", "opacity: 0; cursor: default")
+
+ div {
+ className = ClassName("text-center")
+ style = jso {
+ height = 40.rem
+ }
+
+ div {
+ className = ClassName("error mx-auto mt-5")
+ props.bigText?.let {
+ asDynamic()["data-text"] = it
+ }
+ +"${props.bigText}"
+ }
+
+ p {
+ className = ClassName("lead text-gray-800 mb-3")
+ +"${props.smallText}"
+ }
+
+ if (props.withRouterLink == true) {
+ Navigate {
+ to = "/"
+ this@render.buttonBuilder("Back to the main page", style = "info") { }
+ }
+ } else {
+ a {
+ href = "${window.location.origin}/"
+ buttonBuilder("Back to the main page", style = "info") { }
+ }
+ }
+
+ div {
+ className = ClassName("row mt-3 justify-content-center")
+ div {
+ className = ClassName("col-4")
+ p {
+ className = ClassName("lead text-gray-800")
+ +"Report a problem:"
+ }
+
+ a {
+ className = ClassName("mt-3")
+ href = "https://github.com/saveourtool/save-cloud"
+ img {
+ src = "/img/github.svg"
+ style = jso {
+ width = 5.rem
+ height = 5.rem
+ cursor = "pointer".unsafeCast()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/RegistrationView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/RegistrationView.kt
new file mode 100644
index 0000000000..2fb7247b70
--- /dev/null
+++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/RegistrationView.kt
@@ -0,0 +1,383 @@
+/**
+ * A view for registration
+ */
+
+@file:Suppress("FILE_WILDCARD_IMPORTS", "WildcardImport", "FILE_NAME_MATCH_CLASS")
+
+package com.saveourtool.save.frontend.common.components.views
+
+import com.saveourtool.save.frontend.common.components.basic.avatarForm
+import com.saveourtool.save.frontend.common.components.basic.avatarRenderer
+import com.saveourtool.save.frontend.common.components.inputform.InputTypes
+import com.saveourtool.save.frontend.common.components.inputform.inputTextFormOptional
+import com.saveourtool.save.frontend.common.components.inputform.inputTextFormRequired
+import com.saveourtool.save.frontend.common.components.modal.MAX_Z_INDEX
+import com.saveourtool.save.frontend.common.components.views.usersettings.AVATAR_TITLE
+import com.saveourtool.save.frontend.common.utils.*
+import com.saveourtool.save.frontend.common.utils.UserInfoAwareMutablePropsWithChildren
+import com.saveourtool.save.info.UserInfo
+import com.saveourtool.save.info.UserStatus
+import com.saveourtool.save.utils.AVATARS_PACKS_DIR
+import com.saveourtool.save.utils.AvatarType
+import com.saveourtool.save.utils.CONTENT_LENGTH_CUSTOM
+import com.saveourtool.save.utils.FILE_PART_NAME
+import com.saveourtool.save.validation.FrontendRoutes
+import com.saveourtool.save.validation.isValidLengthName
+import com.saveourtool.save.validation.isValidName
+import com.saveourtool.save.validation.isValidUrl
+
+import js.core.jso
+import org.w3c.fetch.Headers
+import react.*
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.form
+import react.dom.html.ReactHTML.h1
+import react.dom.html.ReactHTML.img
+import react.dom.html.ReactHTML.input
+import react.dom.html.ReactHTML.label
+import react.dom.html.ReactHTML.main
+import react.dom.html.ReactHTML.span
+import react.dom.html.ReactHTML.textarea
+import react.router.dom.Link
+import react.router.useNavigate
+import web.cssom.*
+import web.file.File
+import web.html.InputType
+import web.http.FormData
+import web.window.WindowTarget
+
+import kotlinx.browser.window
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+/**
+ * A Component for registration view
+ */
+@JsExport
+@OptIn(ExperimentalJsExport::class)
+val registrationView: FC = FC { props ->
+ useBackground(Style.INDEX)
+ particles()
+ val useNavigate = useNavigate()
+
+ if (props.userInfo?.status == UserStatus.ACTIVE) {
+ useNavigate(to = "/")
+ }
+
+ useRedirectToIndexIf(props.userInfo?.status) {
+ // life hack ot be sure that props are loaded
+ props.key != null && props.userInfo?.status != UserStatus.CREATED
+ }
+
+ val avatarWindowOpen = useWindowOpenness()
+ val (selectedAvatar, setSelectedAvatar) = useState(props.userInfo?.avatar)
+ val (avatar, setAvatar) = useState(null)
+
+ val (isTermsOfUseOk, setIsTermsOfUseOk) = useState(false)
+ val (conflictErrorMessage, setConflictErrorMessage) = useState(null)
+ val (userInfo, setUserInfo) = useStateFromProps(props.userInfo ?: UserInfo(name = "")) { userInfo ->
+ // weed to process usernames, as some authorization providers pass emails instead of names
+ val atIndex = userInfo.name.indexOf('@')
+ val processedName = if (atIndex >= 0) userInfo.name.substring(0, atIndex) else userInfo.name
+ userInfo.copy(name = processedName)
+ }
+
+ val saveUser = useDeferredRequest {
+ val newUserInfo = userInfo.copy(
+ status = UserStatus.ACTIVE,
+ )
+ val response = post(
+ "$apiUrl/users/save",
+ jsonHeaders,
+ Json.encodeToString(newUserInfo),
+ loadingHandler = ::loadingHandler,
+ responseHandler = ::responseHandlerWithValidation,
+ )
+ if (response.ok) {
+ props.userInfoSetter(userInfo)
+ useNavigate(to = "/${FrontendRoutes.THANKS_FOR_REGISTRATION}")
+ window.location.reload()
+ } else if (response.isConflict()) {
+ setConflictErrorMessage(response.unpackMessage())
+ }
+ }
+
+ val logOut = useDeferredRequest {
+ val replyToLogout = post(
+ "${window.location.origin}/logout",
+ Headers(),
+ "ping",
+ loadingHandler = ::loadingHandler,
+ )
+ if (replyToLogout.ok) {
+ useNavigate(to = "/")
+ window.location.reload()
+ }
+ }
+
+ val saveAvatar = useDeferredRequest {
+ avatar?.let {
+ val response = post(
+ url = "$apiUrl/avatar/upload",
+ params = jso {
+ owner = props.userInfo?.name
+ this.type = AvatarType.USER
+ },
+ Headers().apply { append(CONTENT_LENGTH_CUSTOM, avatar.size.toString()) },
+ FormData().apply { set(FILE_PART_NAME, avatar) },
+ loadingHandler = ::noopLoadingHandler,
+ responseHandler = ::noopResponseHandler,
+ )
+
+ if (response.ok) {
+ window.location.reload()
+ }
+ }
+ }
+
+ val setAvatarFromResources = useDeferredRequest {
+ get(
+ url = "$apiUrl/avatar/avatar-update",
+ params = jso {
+ this.type = AvatarType.USER
+ this.resource = selectedAvatar
+ },
+ jsonHeaders,
+ loadingHandler = ::loadingHandler,
+ )
+ }
+
+ val isWebsiteValid = userInfo.website?.isValidUrl() ?: true
+
+ avatarForm {
+ isOpen = avatarWindowOpen.isOpen()
+ title = AVATAR_TITLE
+ onCloseWindow = {
+ saveAvatar()
+ avatarWindowOpen.closeWindow()
+ }
+ imageUpload = { file ->
+ setAvatar(file)
+ }
+ }
+
+ main {
+ className = ClassName("main-content mt-0 ps")
+ div {
+ className = ClassName("page-header align-items-start min-vh-100")
+ span {
+ className = ClassName("mask bg-gradient-dark opacity-6")
+ }
+ div {
+ className = ClassName("row justify-content-center")
+ div {
+ className = ClassName("col-sm-4")
+ div {
+ className = ClassName("container card o-hidden border-0 shadow-lg my-2 card-body p-0")
+ style = jso {
+ zIndex = (MAX_Z_INDEX - 1).unsafeCast()
+ }
+ div {
+ className = ClassName("p-5 text-center")
+
+ h1 {
+ className = ClassName("h4 text-gray-900 mb-4")
+ +"Set your user name and avatar"
+ }
+
+ div {
+ className = ClassName("row")
+
+ div {
+ className = ClassName("col-3")
+ div {
+ className = ClassName("row d-flex justify-content-center")
+ renderPreparedAvatars(
+ 1..3,
+ setSelectedAvatar,
+ setAvatarFromResources,
+ )
+ }
+ }
+
+ div {
+ className = ClassName("col-6")
+ renderAvatar(avatarWindowOpen, selectedAvatar)
+ }
+
+ div {
+ className = ClassName("col-3")
+ div {
+ className = ClassName("row d-flex justify-content-center")
+ renderPreparedAvatars(
+ 4..6,
+ setSelectedAvatar,
+ setAvatarFromResources,
+ )
+ }
+ }
+ }
+
+ form {
+ div {
+ inputTextFormRequired {
+ form = InputTypes.LOGIN
+ textValue = userInfo.name
+ validInput = userInfo.name.isNotEmpty() && userInfo.name.isValidName() && userInfo.name.isValidLengthName()
+ classes = ""
+ name = "User name"
+ conflictMessage = conflictErrorMessage
+ onChangeFun = { event ->
+ setUserInfo { previousUserInfo -> previousUserInfo.copy(name = event.target.value) }
+ setConflictErrorMessage(null)
+ }
+ }
+ }
+
+ div {
+ className = ClassName("pt-3 font-weight-bold")
+ +"Please enter some information about yourself so that it would be easier for us to approve."
+ }
+
+ div {
+ className = ClassName("pt-3")
+ inputTextFormOptional {
+ form = InputTypes.GITHUB
+ textValue = userInfo.gitHub
+ classes = ""
+ validInput = null
+ onChangeFun = { event ->
+ setUserInfo { previousUserInfo ->
+ previousUserInfo.copy(gitHub = event.target.value.takeIf { it.isNotBlank() })
+ }
+ }
+ }
+ }
+
+ div {
+ className = ClassName("pt-3")
+ inputTextFormOptional {
+ form = InputTypes.WEBSITE
+ textValue = userInfo.website
+ classes = ""
+ validInput = userInfo.website?.isValidUrl()
+ onChangeFun = { event ->
+ setUserInfo { previousUserInfo ->
+ previousUserInfo.copy(website = event.target.value.takeIf { it.isNotBlank() })
+ }
+ }
+ }
+ }
+
+ div {
+ className = ClassName("pt-3")
+ textarea {
+ className = ClassName("form-control")
+ value = userInfo.freeText
+ placeholder = "Additional info"
+ onChange = { event -> setUserInfo { previousUserInfo -> previousUserInfo.copy(freeText = event.target.value) } }
+ }
+ }
+
+ div {
+ className = ClassName("mt-2 form-check row")
+ input {
+ className = ClassName("form-check-input")
+ type = "checkbox".unsafeCast()
+ value = ""
+ id = "terms-of-use"
+ onChange = { setIsTermsOfUseOk(it.target.checked) }
+ }
+ label {
+ className = ClassName("form-check-label")
+ +" I agree with "
+ Link {
+ to = "/${FrontendRoutes.TERMS_OF_USE}"
+ target = "_blank".unsafeCast()
+ +"terms of use"
+ }
+ }
+ }
+
+ buttonBuilder(
+ "Sign up",
+ "info",
+ classes = "mt-4 mr-4",
+ isDisabled = !isTermsOfUseOk || !isWebsiteValid,
+ ) { saveUser() }
+
+ buttonBuilder(
+ "Log out",
+ "danger",
+ classes = "mt-4",
+ ) { logOut() }
+
+ conflictErrorMessage?.let {
+ div {
+ className = ClassName("invalid-feedback d-block")
+ +it
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * @param avatarWindowOpen
+ * @param avatar
+ */
+fun ChildrenBuilder.renderAvatar(
+ avatarWindowOpen: WindowOpenness,
+ avatar: String?,
+) {
+ div {
+ className = ClassName("animated-provider")
+ Link {
+ className = ClassName("btn px-0 pt-0")
+ title = AVATAR_TITLE
+ onClick = {
+ avatarWindowOpen.openWindow()
+ }
+ img {
+ className = ClassName("avatar avatar-user width-full border color-bg-default rounded-circle")
+ src = avatar?.avatarRenderer() ?: AVATAR_PROFILE_PLACEHOLDER
+ style = jso {
+ height = 16.rem
+ width = 16.rem
+ }
+ }
+ }
+ }
+}
+
+private fun ChildrenBuilder.renderPreparedAvatars(
+ avatarsRange: IntRange,
+ setSelectedAvatar: StateSetter,
+ setAvatarFromResources: () -> Unit = { },
+) {
+ for (i in avatarsRange) {
+ val avatar = "$AVATARS_PACKS_DIR/avatar$i.png"
+ div {
+ className = ClassName("animated-provider")
+ img {
+ className =
+ ClassName("avatar avatar-user width-full border color-bg-default rounded-circle mt-1")
+ src = avatar
+ style = jso {
+ height = 5.1.rem
+ width = 5.1.rem
+ cursor = Cursor.pointer
+ }
+ onClick = {
+ setSelectedAvatar(avatar)
+ setAvatarFromResources()
+ }
+ }
+ }
+ }
+}
diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileNewUsersTab.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileNewUsersTab.kt
new file mode 100644
index 0000000000..78f68452bd
--- /dev/null
+++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileNewUsersTab.kt
@@ -0,0 +1,72 @@
+@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE")
+
+package com.saveourtool.save.frontend.common.components.views.userprofile
+
+import com.saveourtool.save.frontend.common.components.basic.renderUserAvatarWithName
+import com.saveourtool.save.frontend.common.components.tables.TableProps
+import com.saveourtool.save.frontend.common.components.tables.columns
+import com.saveourtool.save.frontend.common.components.tables.tableComponent
+import com.saveourtool.save.frontend.common.components.tables.value
+import com.saveourtool.save.frontend.common.utils.*
+import com.saveourtool.save.info.UserInfo
+import react.FC
+import react.Fragment
+import react.Props
+import react.create
+import react.dom.html.ReactHTML.td
+import web.cssom.ClassName
+import web.cssom.rem
+
+val renderNewUsersTableForProfileView: FC = FC {
+ @Suppress(
+ "TYPE_ALIAS",
+ "MAGIC_NUMBER",
+ )
+ val newUsersTable: FC> = tableComponent(
+ columns = {
+ columns {
+ column(id = "name", header = "Name", { name }) { cellContext ->
+ Fragment.create {
+ td {
+ className = ClassName("align-middle")
+ renderUserAvatarWithName(cellContext.row.original) {
+ height = 3.rem
+ width = 3.rem
+ }
+ }
+ }
+ }
+ column(id = "originalName", header = "Original login") { cellContext ->
+ Fragment.create {
+ td {
+ className = ClassName("align-middle text-center")
+ +cellContext.value.originalLogins.firstNotNullOfOrNull { it.value }
+ }
+ }
+ }
+ column(id = "source", header = "Source") { cellContext ->
+ Fragment.create {
+ td {
+ className = ClassName("align-middle text-center")
+ +cellContext.value.originalLogins.firstNotNullOfOrNull { it.key }
+ }
+ }
+ }
+ }
+ },
+ isTransparentGrid = true,
+ )
+
+ newUsersTable {
+ getData = { _, _ ->
+ get(
+ url = "$apiUrl/users/new-users",
+ headers = jsonHeaders,
+ loadingHandler = ::noopLoadingHandler,
+ responseHandler = ::noopResponseHandler,
+ ).unsafeMap {
+ it.decodeFromJsonString()
+ }
+ }
+ }
+}
diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileView.kt
new file mode 100644
index 0000000000..6f87572a21
--- /dev/null
+++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileView.kt
@@ -0,0 +1,489 @@
+/**
+ * View for UserProfile
+ */
+
+package com.saveourtool.save.frontend.common.components.views.userprofile
+
+import com.saveourtool.save.entities.OrganizationDto
+import com.saveourtool.save.frontend.TabMenuBar
+import com.saveourtool.save.frontend.common.components.basic.renderAvatar
+import com.saveourtool.save.frontend.common.components.inputform.InputTypes
+import com.saveourtool.save.frontend.common.components.modal.displayModal
+import com.saveourtool.save.frontend.common.components.modal.mediumTransparentModalStyle
+import com.saveourtool.save.frontend.common.components.views.vuln.vulnerabilityTableComponent
+import com.saveourtool.save.frontend.common.externals.fontawesome.*
+import com.saveourtool.save.frontend.common.utils.*
+import com.saveourtool.save.info.UserInfo
+import com.saveourtool.save.info.UserStatus
+import com.saveourtool.save.utils.*
+import com.saveourtool.save.validation.FrontendRoutes
+
+import js.core.jso
+import react.*
+import react.dom.aria.ariaDescribedBy
+import react.dom.html.AnchorHTMLAttributes
+import react.dom.html.HTMLAttributes
+import react.dom.html.ReactHTML.a
+import react.dom.html.ReactHTML.div
+import react.dom.html.ReactHTML.h3
+import react.dom.html.ReactHTML.h5
+import react.dom.html.ReactHTML.hr
+import react.dom.html.ReactHTML.input
+import react.dom.html.ReactHTML.label
+import react.dom.html.ReactHTML.p
+import react.dom.html.ReactHTML.textarea
+import react.router.dom.Link
+import react.router.useNavigate
+import web.cssom.*
+import web.html.HTMLAnchorElement
+import web.html.HTMLHeadingElement
+import web.html.InputType
+
+val userProfileView: FC